diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000000000..f9316cdcd7dba3 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,21 @@ +# Apply this file to CodeQL default setup with the repository property: +# github-codeql-config-file: ./.github/codeql/codeql-config.yml +paths-ignore: + # Keep directory exclusions scoped: product code imports helpers from some + # extension src/test directories. + - 'test/**' + - 'src/**/test/**' + - 'build/**/test/**' + - 'cli/tests/**' + - '.eslint-plugin-local/tests/**' + - 'extensions/*/test/**' + - 'extensions/*/tests/**' + # Keep exact suffixes: themes.test.contribution.ts ships in the product. + - '**/*.test.js' + - '**/*.test.jsx' + - '**/*.test.mjs' + - '**/*.test.cjs' + - '**/*.test.ts' + - '**/*.test.tsx' + - '**/*.test.mts' + - '**/*.test.cts' diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index c1c8809640869f..015e6be556186d 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -182,9 +182,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "peer": true, "engines": { @@ -292,21 +292,21 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^1.0.5", "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" @@ -316,20 +316,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -659,9 +645,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -806,9 +792,9 @@ } }, "node_modules/hono": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", - "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "peer": true, "engines": { @@ -861,9 +847,9 @@ "peer": true }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "peer": true, "engines": { diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 7482246e1435b1..887285c3d96c8b 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -439,11 +439,15 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "dev": true, "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, "engines": { "node": ">= 12" } @@ -484,6 +488,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -904,13 +915,13 @@ } }, "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.1.1", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { @@ -933,6 +944,13 @@ "node": ">= 14" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index 0c3b56bf3ea736..5d8ff26b966cf1 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -572,6 +572,16 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid } } + private logSessionChange(source: 'secret-storage' | 'interactive-login', added: number, removed: number, changed: number): void { + const kind = added > 0 && removed > 0 ? 'replacement' : added > 0 ? 'addition' : removed > 0 ? 'removal' : 'change'; + const message = `Session change: source=${source}, kind=${kind}, added=${added}, removed=${removed}, changed=${changed}.`; + if (kind === 'replacement') { + this._logger.info(message); + } else { + this._logger.trace(message); + } + } + private async checkForUpdates() { // Only the persisted sessions are reconciled against the Keychain: transient sessions do not // exist there, so they must never be diffed against it and reported as removed. @@ -601,6 +611,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid }); if (added.length || removed.length) { + this.logSessionChange('secret-storage', added.length, removed.length, 0); this._sessionChangeEmitter.fire({ added, removed, changed: [] }); } } @@ -748,6 +759,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid } await this.storeSessions(sessions); + this.logSessionChange('interactive-login', 1, removed.length, 0); this._sessionChangeEmitter.fire({ added: [session], removed, changed: [] }); this._logger.info('Login success!'); diff --git a/package-lock.json b/package-lock.json index d2855e0158030d..470dab9e6c812d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,16 +44,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "foundry-local-sdk": "1.2.3", @@ -2127,13 +2127,13 @@ "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, "node_modules/@microsoft/dev-tunnels-connections": { - "version": "1.3.55", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.55.tgz", - "integrity": "sha512-Vr60/UrmSJipuZ9hrL6VjKefoNpkuESfpCO7omLy9GNQXY9aE6uieUxtXLNa+79+T5f/wLGqiF9rvAGh2S/KdQ==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.50.tgz", + "integrity": "sha512-L3vUE7jiW4tzx1D+sEsuCW5UKK3CjYFIxOtjZF/MN8ZCM2a2MVtIijctjU+/Y6Gi+ohvrOZKoqSZFRD12bpAgA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.55", - "@microsoft/dev-tunnels-management": "1.3.55", + "@microsoft/dev-tunnels-contracts": "1.3.50", + "@microsoft/dev-tunnels-management": "1.3.50", "await-semaphore": "^0.1.3", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -2143,8 +2143,8 @@ "websocket": "^1.0.28" }, "peerDependencies": { - "@microsoft/dev-tunnels-ssh": "^3.12.42", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.42" + "@microsoft/dev-tunnels-ssh": "^3.12.29", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.29" } }, "node_modules/@microsoft/dev-tunnels-connections/node_modules/vscode-jsonrpc": { @@ -2157,9 +2157,9 @@ } }, "node_modules/@microsoft/dev-tunnels-contracts": { - "version": "1.3.55", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.55.tgz", - "integrity": "sha512-1qms2x+DKIYTxWIQaISWCtiFzvHsMzOqu7M1c3yoxFB7PG9KNfgS5MS7Y0Ftazckwm0M+61mUM2SueBTK5SsDQ==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.50.tgz", + "integrity": "sha512-R4G/h939dL3UOui/69cKmRNZVf+3IpO6bMWxOgt4NYjF2IvWaSWBoc+u2AdZuZi+0ZHatWXvjoCDLNrV4pUyLQ==", "license": "MIT", "dependencies": { "buffer": "^5.2.1", @@ -2177,12 +2177,12 @@ } }, "node_modules/@microsoft/dev-tunnels-management": { - "version": "1.3.55", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.55.tgz", - "integrity": "sha512-qUHuRH+3ZzBwmOmUeW/38EC09yT8OfcSgEOihnTn/uqu9UFiJSssg4o3R9cIpEFJgRy3LrY48HV3XFJrqrd7KA==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.50.tgz", + "integrity": "sha512-sWK0CrBcmiNyeb3HztocR+Gd5ROfKjyixhxfSJ+TlGIj9Y4i3DggBDFZeaKGql5LbJN5+v13tOggF2vhn8OEfA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.55", + "@microsoft/dev-tunnels-contracts": "1.3.50", "axios": "^1.8.4", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -5386,27 +5386,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", - "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", + "version": "0.3.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.302.tgz", + "integrity": "sha512-KRH/7iyVE97zNjIhZI674uY62cuekMofSLOxG1TkOdSCofxvgOgbPL6C7R3BrWSG7MX28tM7TzqfdSKKqG2U7g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", - "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.300.tgz", + "integrity": "sha512-eF19wskh7io1KIFMHkJW0yMSTrDdvALp0ZjI4A27czpdTLquyB8LN0w+wNnpOU7D+B4CZp/8LxNta/LGMRIdCg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", - "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", + "version": "0.11.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.300.tgz", + "integrity": "sha512-f+Uol/BSxHSLnqGDjixstqYHwI/cPgUOfCqP5Lc591xe/MlAgaFIBd8xMEAlUnJnz6/jIVoQMSNNhpk8a4asrQ==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -5416,7 +5416,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -5429,63 +5429,63 @@ } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", - "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", + "version": "0.3.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.300.tgz", + "integrity": "sha512-zYxoFTP+5rkdzKYG0imSDJPUnJ6FY+UsqOROMgw8v86Ej9ky6O9K35nH2HmfAp+JfXpC/5oCZ9vFu0KFNe73qw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", - "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", + "version": "0.17.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.300.tgz", + "integrity": "sha512-D3kPBm0qB+AWP+qzgq7JUSCMZOjYxAX9g+bbmLiCBKhgt6dWk40GVG2YgFQRuS55Guj0ooVu93Y/nKEZ7jKxQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", - "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", + "version": "0.15.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.300.tgz", + "integrity": "sha512-OCcnMMOCP+p5pIuXIro+u+wfNkSD8J2CZUqmyfTULxgs7jc+vyDL6UiaKmbs2q6MUcq4F9P5Oju3J8d4SEKOmQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", - "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.300.tgz", + "integrity": "sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.298", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", - "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", + "version": "0.20.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.299.tgz", + "integrity": "sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.301.tgz", - "integrity": "sha512-ApQUwq3BlHA8xlOeBKnC+1l+g95JHVl9bg7EujtUBZA9aKtweyOF9PEd/1larB0c4UF5XAf9lWjj+ICNZNR/1w==", + "version": "6.1.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.302.tgz", + "integrity": "sha512-UZTvl9zme7wu4q8H5dvudJ7jDYk2BkXbDpiuhbDj0eDg0smoDHk5vcHDfy8zokwDCIHCZXV5Yzy45Wi7DC7dXQ==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.302", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", - "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", + "version": "6.1.0-beta.303", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.303.tgz", + "integrity": "sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index 90c815ecb8f8bd..0e1cb64f4ba8eb 100644 --- a/package.json +++ b/package.json @@ -133,16 +133,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "foundry-local-sdk": "1.2.3", diff --git a/remote/package-lock.json b/remote/package-lock.json index 4e39070e1ada5c..c24cdb237b936a 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -28,16 +28,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -1079,27 +1079,27 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", - "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", + "version": "0.3.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.302.tgz", + "integrity": "sha512-KRH/7iyVE97zNjIhZI674uY62cuekMofSLOxG1TkOdSCofxvgOgbPL6C7R3BrWSG7MX28tM7TzqfdSKKqG2U7g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", - "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.300.tgz", + "integrity": "sha512-eF19wskh7io1KIFMHkJW0yMSTrDdvALp0ZjI4A27czpdTLquyB8LN0w+wNnpOU7D+B4CZp/8LxNta/LGMRIdCg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", - "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", + "version": "0.11.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.300.tgz", + "integrity": "sha512-f+Uol/BSxHSLnqGDjixstqYHwI/cPgUOfCqP5Lc591xe/MlAgaFIBd8xMEAlUnJnz6/jIVoQMSNNhpk8a4asrQ==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -1109,67 +1109,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", - "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", + "version": "0.3.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.300.tgz", + "integrity": "sha512-zYxoFTP+5rkdzKYG0imSDJPUnJ6FY+UsqOROMgw8v86Ej9ky6O9K35nH2HmfAp+JfXpC/5oCZ9vFu0KFNe73qw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", - "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", + "version": "0.17.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.300.tgz", + "integrity": "sha512-D3kPBm0qB+AWP+qzgq7JUSCMZOjYxAX9g+bbmLiCBKhgt6dWk40GVG2YgFQRuS55Guj0ooVu93Y/nKEZ7jKxQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", - "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", + "version": "0.15.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.300.tgz", + "integrity": "sha512-OCcnMMOCP+p5pIuXIro+u+wfNkSD8J2CZUqmyfTULxgs7jc+vyDL6UiaKmbs2q6MUcq4F9P5Oju3J8d4SEKOmQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", - "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.300.tgz", + "integrity": "sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.298", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", - "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", + "version": "0.20.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.299.tgz", + "integrity": "sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.301.tgz", - "integrity": "sha512-ApQUwq3BlHA8xlOeBKnC+1l+g95JHVl9bg7EujtUBZA9aKtweyOF9PEd/1larB0c4UF5XAf9lWjj+ICNZNR/1w==", + "version": "6.1.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.302.tgz", + "integrity": "sha512-UZTvl9zme7wu4q8H5dvudJ7jDYk2BkXbDpiuhbDj0eDg0smoDHk5vcHDfy8zokwDCIHCZXV5Yzy45Wi7DC7dXQ==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.302", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", - "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", + "version": "6.1.0-beta.303", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.303.tgz", + "integrity": "sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index e70cd766e4eae0..f8ce30a06757ad 100644 --- a/remote/package.json +++ b/remote/package.json @@ -23,16 +23,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 0b54259cbb8cb3..92bd7bacbaa711 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/xterm": "^6.1.0-beta.303", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.4.3", @@ -100,27 +100,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", - "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", + "version": "0.3.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.302.tgz", + "integrity": "sha512-KRH/7iyVE97zNjIhZI674uY62cuekMofSLOxG1TkOdSCofxvgOgbPL6C7R3BrWSG7MX28tM7TzqfdSKKqG2U7g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", - "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.300.tgz", + "integrity": "sha512-eF19wskh7io1KIFMHkJW0yMSTrDdvALp0ZjI4A27czpdTLquyB8LN0w+wNnpOU7D+B4CZp/8LxNta/LGMRIdCg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", - "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", + "version": "0.11.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.300.tgz", + "integrity": "sha512-f+Uol/BSxHSLnqGDjixstqYHwI/cPgUOfCqP5Lc591xe/MlAgaFIBd8xMEAlUnJnz6/jIVoQMSNNhpk8a4asrQ==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -130,58 +130,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", - "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", + "version": "0.3.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.300.tgz", + "integrity": "sha512-zYxoFTP+5rkdzKYG0imSDJPUnJ6FY+UsqOROMgw8v86Ej9ky6O9K35nH2HmfAp+JfXpC/5oCZ9vFu0KFNe73qw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", - "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", + "version": "0.17.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.300.tgz", + "integrity": "sha512-D3kPBm0qB+AWP+qzgq7JUSCMZOjYxAX9g+bbmLiCBKhgt6dWk40GVG2YgFQRuS55Guj0ooVu93Y/nKEZ7jKxQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", - "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", + "version": "0.15.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.300.tgz", + "integrity": "sha512-OCcnMMOCP+p5pIuXIro+u+wfNkSD8J2CZUqmyfTULxgs7jc+vyDL6UiaKmbs2q6MUcq4F9P5Oju3J8d4SEKOmQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", - "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.300.tgz", + "integrity": "sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.298", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", - "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", + "version": "0.20.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.299.tgz", + "integrity": "sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.302", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", - "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", + "version": "6.1.0-beta.303", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.303.tgz", + "integrity": "sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 11f0365cab1b76..ca0b95f2ef6265 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/xterm": "^6.1.0-beta.303", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.4.3", diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index aee77ac1234f98..c1050dd3ce57d8 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -1560,9 +1560,11 @@ export class ListView implements IListView { } private getVisibleRange(renderTop: number, renderHeight: number): IRange { + const start = this.rangeMap.indexAt(renderTop); return { - start: this.rangeMap.indexAt(renderTop), - end: this.rangeMap.indexAfter(renderTop + renderHeight - 1) + start, + // Clamp so a collapsed viewport (renderHeight <= 0) cannot produce an inverted range. + end: Math.max(start, this.rangeMap.indexAfter(renderTop + renderHeight - 1)) }; } diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 8d5e2ff6ad60d7..b1d076fd5e0614 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -523,6 +523,13 @@ export class ToolBar extends Disposable { // Hide actions from the right while (minimumWidth > containerWidth && this.actionBar.length() > 0) { + if ( + this.options.responsiveBehavior?.minItems !== undefined + && getVisiblePrimaryActionIndexes().length <= this.options.responsiveBehavior.minItems + ) { + break; + } + const index = getVisiblePrimaryActionIndexes().at(-1); if (index === undefined) { break; diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 98f603971a0762..4d5dc86be9b436 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -577,4 +577,29 @@ suite('ListView', function () { element.remove(); } }); + + test('does not throw when laid out with a collapsed viewport and zero-height dynamic items', function () { + type TestElement = { height: number }; + const delegate: IListVirtualDelegate = { + getHeight() { return 0; }, + getTemplateId() { return 'template'; }, + getDynamicHeight(element) { return element.height; } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate() { }, + renderElement() { }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 0 }, { height: 0 }, { height: 0 }]; + const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); + try { + // Collapsing the viewport before splicing zero-height items previously yielded an inverted range that crashed probeDynamicHeights. + listView.layout(0, 200); + assert.doesNotThrow(() => listView.splice(0, 0, elements)); + } finally { + listView.dispose(); + } + }); }); diff --git a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts index 95bbafbbe10f9b..17f71acf90a0b5 100644 --- a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts +++ b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts @@ -516,6 +516,42 @@ suite('ToolBar', () => { }); }); + test('keeps the configured minimum actions visible across repeated relayouts', () => { + let availableWidth = 300; + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 2, + actionMinWidth: 22, + getAvailableWidth: () => availableWidth, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 60), + })); + toolbar.setActions([ + store.add(new Action('attach', 'Attach')), + store.add(new Action('agent', 'Agent')), + store.add(new Action('model', 'Model')), + store.add(new Action('settings', 'Settings')), + ]); + + const states: string[][] = []; + for (const width of [100, 300, 100, 300, 100, 300]) { + availableWidth = width; + toolbar.relayout(); + states.push(Array.from({ length: toolbar.getItemsLength() }, (_, index) => toolbar.getItemAction(index)?.id ?? '')); + } + + assert.deepStrictEqual(states, [ + ['attach', 'agent', ToggleMenuAction.ID], + ['attach', 'agent', 'model', 'settings'], + ['attach', 'agent', ToggleMenuAction.ID], + ['attach', 'agent', 'model', 'settings'], + ['attach', 'agent', ToggleMenuAction.ID], + ['attach', 'agent', 'model', 'settings'], + ]); + }); + test('uses overflow-specific proxy actions', async () => { const runs: string[] = []; let overflowAnchor: HTMLElement | undefined; diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 2efbd02face437..f1d9ee618eed1b 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1144,6 +1144,9 @@ export interface IAgent { /** Return bounded diagnostics for an in-flight turn when supported. */ getTurnDiagnosticSnapshot?(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot | undefined; + /** Record the host-remapped turn for a completed provider model call. */ + recordModelCallTurnCorrelation?(chat: URI, modelCallId: string, turnId: string): void; + // ---- Active clients and interaction ------------------------------------ /** Get or create one client's contribution handle for an exact chat. */ diff --git a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts index 71a89343be1af6..aecec425b6f55c 100644 --- a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts @@ -42,6 +42,8 @@ export interface IAgentHostCompletionAction { export interface ICommandCompletionAttachmentMeta { /** The slash command name (without the leading `/`). */ readonly command: string; + /** Whether this text-dispatched command originated from a runtime skill. */ + readonly isSkill?: true; /** Optional human-readable description of the command. */ readonly description?: string; /** @@ -96,6 +98,7 @@ export function readCompletionAttachmentMeta(attachment: SimpleMessageAttachment return { kind: 'command', command: meta['command'], + ...(meta['isSkill'] === true ? { isSkill: true } : {}), ...(typeof meta['description'] === 'string' ? { description: meta['description'] } : {}), ...(typeof meta['argumentHint'] === 'string' ? { argumentHint: meta['argumentHint'] } : {}), ...(action ? { action } : {}), @@ -121,6 +124,9 @@ export function readCompletionAttachmentMeta(attachment: SimpleMessageAttachment */ export function toCommandCompletionAttachmentMeta(meta: ICommandCompletionAttachmentMeta): Record { const result: Record = { command: meta.command }; + if (meta.isSkill === true) { + result['isSkill'] = true; + } if (meta.description !== undefined) { result['description'] = meta.description; } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 60eed5bf1c4ab8..e1773893947b0c 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -620,7 +620,7 @@ export class AgentSideEffects extends Disposable { const subTurnId = this._stateManager.getActiveTurnId(subagentSession.chatUri); if (subTurnId) { if (signal.kind === 'model_call_completed') { - this._recordModelCallCompleted(signal, subagentSession.chatUri, subTurnId, 'remap'); + this._recordModelCallCompleted(agent, signal, subagentSession.chatUri, subTurnId, 'remap'); } else { this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, 'remap', agent); } @@ -670,7 +670,7 @@ export class AgentSideEffects extends Disposable { const turnId = this._stateManager.getActiveTurnId(sessionKey); if (turnId) { if (signal.kind === 'model_call_completed') { - this._recordModelCallCompleted(signal, sessionKey, turnId, 'preserve'); + this._recordModelCallCompleted(agent, signal, sessionKey, turnId, 'preserve'); } else { this._dispatchActionForSession(signal, sessionKey, turnId, 'preserve', agent); } @@ -893,11 +893,12 @@ export class AgentSideEffects extends Disposable { return `${chat}\0${turnId}`; } - private _recordModelCallCompleted(signal: IAgentModelCallCompletedSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting): void { + private _recordModelCallCompleted(agent: IAgent, signal: IAgentModelCallCompletedSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting): void { if (signal.turnId !== turnId && turnIdRouting === 'preserve') { this._logService.trace(`[AgentSideEffects] Dropping stale model_call_completed for ${sessionKey}: producerTurnId=${signal.turnId}, activeTurnId=${turnId}`); return; } + agent.recordModelCallTurnCorrelation?.(signal.resource, signal.modelCallId, turnId); this._turnTracker.modelCallCompleted(sessionKey, turnId, signal.modelCallId); } @@ -994,7 +995,8 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(parentSessionUri); if (agent) { - this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(parentSessionUri)?.config); + this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } @@ -1069,7 +1071,8 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(subagent.sessionUri); if (agent) { - this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(subagent.sessionUri)?.config); + this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } this._subagentChats.set({ ...subagent, immediateParentChatUri: correlatedParentChatUri, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 060ab9762ae808..a2e689442aac85 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1652,7 +1652,7 @@ export class CopilotAgent extends Disposable implements IAgent { const additionalProperties = { initiatorClientType: this._clientTypeForTelemetry(notification.sessionId) }; const router = this._githubTelemetryRouter; if (!router?.isTarget(notification)) { - this._gitHubTelemetryForwarder.forward(notification, this._turnIdForTelemetry(notification.sessionId)); + await this._forwardGitHubTelemetry(notification); return; } if (!notification.restricted) { @@ -1691,8 +1691,31 @@ export class CopilotAgent extends Disposable implements IAgent { : AgentHostClientType.Unknown; } - private _turnIdForTelemetry(sdkSessionId: string | undefined): string | undefined { - return sdkSessionId ? this._findSessionBySdkId(sdkSessionId)?.currentTurnId : undefined; + private async _forwardGitHubTelemetry(notification: GitHubTelemetryNotification): Promise { + const session = notification.sessionId ? this._findSessionBySdkId(notification.sessionId) : undefined; + if (!session) { + this._gitHubTelemetryForwarder.forward(notification); + return; + } + const fallbackTurnId = session.currentTurnId; + const event = notification.event; + if (event.kind === 'response.success' || event.kind === 'response.error') { + const modelCallId = event.properties.modelCallId ?? event.model_call_id; + if (typeof modelCallId === 'string') { + const correlatedTurnId = session.modelCallTurnCorrelation.take(modelCallId); + if (correlatedTurnId) { + this._gitHubTelemetryForwarder.forward(notification, correlatedTurnId); + return; + } + if (event.properties.initiatorType === 'agent') { + const delayedTurnId = await session.modelCallTurnCorrelation.wait(modelCallId); + this._gitHubTelemetryForwarder.forward(notification, delayedTurnId); + return; + } + session.modelCallTurnCorrelation.markResponseForwarded(modelCallId); + } + } + this._gitHubTelemetryForwarder.forward(notification, fallbackTurnId); } /** @@ -2983,6 +3006,10 @@ export class CopilotAgent extends Disposable implements IAgent { return session.getTurnDiagnosticSnapshot(turnId) ?? { state: 'missingTurn' }; } + recordModelCallTurnCorrelation(chat: URI, modelCallId: string, turnId: string): void { + this._findChatByUri(chat)?.modelCallTurnCorrelation.record(modelCallId, turnId); + } + /** Creates one exact chat backing: fresh, deferred, imported, or forked. */ private async _createChat(chat: URI, context: IAgentChatContext, options: IAgentCreateChatOptions = {}): Promise { const scope = context.configurationResource; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 99587b3fafb3d8..92df62f545f212 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -86,6 +86,7 @@ import type { ErrorInfo, ProtectedResourceMetadata } from '../../common/state/pr import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js'; import { createCopilotFailureCorrelation, reportCopilotModelCallFailure, reportCopilotSdkSessionError } from './copilotFailureTelemetry.js'; import { reportCopilotTodoStoreOperation } from './copilotTodoStoreTelemetry.js'; +import { ModelCallTurnCorrelation } from './modelCallTurnCorrelation.js'; type CopilotSdkAttachment = Required['attachments'][number]; type CopilotCommandInvocationResult = Awaited>; @@ -747,6 +748,7 @@ export class CopilotAgentSession extends Disposable { */ private readonly _parentToolCallIdsByAgentId = new Map(); private readonly _rootTurnIdBySubagentToolCallId = new Map(); + readonly modelCallTurnCorrelation = new ModelCallTurnCorrelation(); private readonly _subagentDirectUsageByToolCallId = new Map(); private readonly _lastSubagentUsageByToolCallId = new Map(); /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index af8c493caa82de..7669c43571dca0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -225,6 +225,7 @@ export class CopilotGitHubTelemetryForwarder { kind: event.kind, restricted: notification.restricted, }; + delete data.secondary_assignment_context; if (event.kind === 'response.success' || event.kind === 'response.error') { if (agentHostTurnId) { data.turnId = agentHostTurnId; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts b/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts index f371eeaae33ab0..f2da2e33b4ad78 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts @@ -7,9 +7,9 @@ import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; import { isValidAssignmentContext } from '../../../telemetry/common/assignmentContext.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -const SECONDARY_ASSIGNMENT_CONTEXT_PROPERTY = 'secondary_assignment_context'; +const SECONDARY_ASSIGNMENT_CONTEXT_PROPERTY = 'secondary.assignmentcontext'; -// __GDPR__COMMON__ "secondary_assignment_context" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Secondary experiment assignment context assigned by CAPI during Copilot model calls." } +// __GDPR__COMMON__ "secondary.assignmentcontext" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Secondary experiment assignment context assigned by CAPI during Copilot model calls." } export class CopilotSecondaryAssignmentContext { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 6e7fa13f72424c..400a051938316c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -221,6 +221,7 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti label: insertText, _meta: toCommandCompletionAttachmentMeta({ command: command.name, + ...(command.kind === 'skill' ? { isSkill: true } : {}), ...(description !== undefined ? { description } : {}), ...(argumentHint !== undefined ? { argumentHint } : {}) }), diff --git a/src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts b/src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts new file mode 100644 index 00000000000000..046f4f214eb3c0 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, raceTimeout } from '../../../../base/common/async.js'; +import { LRUCache } from '../../../../base/common/map.js'; + +const DEFAULT_TIMEOUT_MS = 100; +const DEFAULT_CACHE_LIMIT = 1000; + +/** Correlates model-call response telemetry with host-remapped Agent Host turns. */ +export class ModelCallTurnCorrelation { + private readonly _turnIdsByModelCallId: LRUCache; + private readonly _pendingTurnIdsByModelCallId = new Map>(); + private readonly _forwardedModelCallIdsAwaitingCorrelation: LRUCache; + private readonly _timeoutMs: number; + + constructor(options: { readonly timeoutMs?: number; readonly cacheLimit?: number } = {}) { + this._timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const cacheLimit = options.cacheLimit ?? DEFAULT_CACHE_LIMIT; + this._turnIdsByModelCallId = new LRUCache(cacheLimit); + this._forwardedModelCallIdsAwaitingCorrelation = new LRUCache(cacheLimit); + } + + record(modelCallId: string, turnId: string): void { + if (this._forwardedModelCallIdsAwaitingCorrelation.delete(modelCallId)) { + return; + } + const pending = this._pendingTurnIdsByModelCallId.get(modelCallId); + if (pending) { + this._pendingTurnIdsByModelCallId.delete(modelCallId); + pending.complete(turnId); + return; + } + this._turnIdsByModelCallId.set(modelCallId, turnId); + } + + take(modelCallId: string): string | undefined { + const turnId = this._turnIdsByModelCallId.get(modelCallId); + this._turnIdsByModelCallId.delete(modelCallId); + return turnId; + } + + markResponseForwarded(modelCallId: string): void { + this._turnIdsByModelCallId.delete(modelCallId); + this._forwardedModelCallIdsAwaitingCorrelation.set(modelCallId, true); + } + + async wait(modelCallId: string): Promise { + const existing = this.take(modelCallId); + if (existing) { + return existing; + } + if (this._forwardedModelCallIdsAwaitingCorrelation.has(modelCallId)) { + return undefined; + } + const pending = new DeferredPromise(); + this._pendingTurnIdsByModelCallId.set(modelCallId, pending); + const turnId = await raceTimeout(pending.p, this._timeoutMs); + if (this._pendingTurnIdsByModelCallId.get(modelCallId) === pending) { + this._pendingTurnIdsByModelCallId.delete(modelCallId); + } + if (turnId === undefined) { + this.markResponseForwarded(modelCallId); + } + return turnId; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts index 21cb4023a8f193..d9426746d6b21f 100644 --- a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts +++ b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isGpt56Model } from './modelIdentifiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; export { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -18,20 +19,16 @@ export const NON_DEFERRED_CLIENT_TOOL_NAMES: ReadonlySet = new Set { assert.deepStrictEqual(cmd, { command: 'rename' }); assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmd)), { kind: 'command', command: 'rename' }); - const cmdWithHint = toCommandCompletionAttachmentMeta({ command: 'rename', argumentHint: 'New name', description: undefined }); - assert.deepStrictEqual(cmdWithHint, { command: 'rename', argumentHint: 'New name' }); - assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmdWithHint)), { kind: 'command', command: 'rename', argumentHint: 'New name' }); + const cmdWithHint = toCommandCompletionAttachmentMeta({ command: 'rename', isSkill: true, argumentHint: 'New name', description: undefined }); + assert.deepStrictEqual(cmdWithHint, { command: 'rename', isSkill: true, argumentHint: 'New name' }); + assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmdWithHint)), { kind: 'command', command: 'rename', isSkill: true, argumentHint: 'New name' }); const skill = toSkillCompletionAttachmentMeta({ uri: 'file:///s/SKILL.md', name: 'mon', displayName: 'mon', description: undefined }); assert.deepStrictEqual(skill, { uri: 'file:///s/SKILL.md', name: 'mon', displayName: 'mon' }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index ebce254637842b..006a2871ef2412 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -387,6 +387,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('attributes subagent model responses only to the subagent turn', () => { setupSession(); + setSessionConfig({ mode: 'plan' }); startTurn('turn-parent'); const subagentChatUri = buildSubagentChatUri(sessionUri, 'call-subagent'); stateManager.addChat(sessionKey, subagentChatUri); @@ -417,13 +418,23 @@ suite('AgentSideEffects — turn tracker telemetry', () => { fire({ type: ActionType.ChatTurnComplete, turnId: subagentTurnId, duration: 1000 }, subagentChatUri); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-parent', duration: 1000 }); - assert.deepStrictEqual(completedEvents().map(event => { - const data = event.data as Record; - return { isSubagentSession: data.isSubagentSession, modelCallCount: data.modelCallCount }; - }), [ - { isSubagentSession: true, modelCallCount: 1 }, - { isSubagentSession: false, modelCallCount: 0 }, - ]); + assert.deepStrictEqual({ + completed: completedEvents().map(event => { + const data = event.data as Record; + return { isSubagentSession: data.isSubagentSession, interactionMode: data.interactionMode, modelCallCount: data.modelCallCount }; + }), + correlations: agent.modelCallTurnCorrelationCalls.map(({ chat, ...correlation }) => ({ chat: chat.toString(), ...correlation })), + }, { + completed: [ + { isSubagentSession: true, interactionMode: 'plan', modelCallCount: 1 }, + { isSubagentSession: false, interactionMode: 'plan', modelCallCount: 0 }, + ], + correlations: [{ + chat: defaultChatUri, + modelCallId: 'subagent-model-call', + turnId: subagentTurnId, + }], + }); }); test('correlates first-level and nested subagent turns with their immediate parent', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 08073d69d59a83..d4c6c296a3df7f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1167,7 +1167,7 @@ suite('CopilotAgent', () => { }); assert.deepStrictEqual(telemetryService.experimentProperties, { - secondary_assignment_context: 'secondary:1', + 'secondary.assignmentcontext': 'secondary:1', }); } finally { await disposeAgent(agent); @@ -1214,47 +1214,67 @@ suite('CopilotAgent', () => { const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; assert.ok(forward); - chatEntriesBySdkId(agent).set('active-session', { - chatSession: { currentTurnId: 'turn-1' } as CopilotAgentSession, - dispose() { }, - }); - chatEntriesBySdkId(agent).set('second-active-session', { - chatSession: { currentTurnId: 'turn-2' } as CopilotAgentSession, - dispose() { }, + const subagentCorrelation = new DeferredPromise(); + const forwardedModelCallIds: string[] = []; + const activeSession: Pick & { + modelCallTurnCorrelation: Pick; + } = { + currentTurnId: 'turn-1', + modelCallTurnCorrelation: { + take: () => undefined, + wait: modelCallId => modelCallId === 'unresolved-model-call' ? Promise.resolve(undefined) : subagentCorrelation.p, + markResponseForwarded: modelCallId => forwardedModelCallIds.push(modelCallId), + }, + }; + setLiveChatStub(agent, 'active-session', activeSession); + setLiveChatStub(agent, 'second-active-session', { + currentTurnId: 'turn-2', }); - chatEntriesBySdkId(agent).set('idle-session', { - chatSession: { currentTurnId: undefined } as CopilotAgentSession, - dispose() { }, + setLiveChatStub(agent, 'idle-session', { + currentTurnId: undefined, }); - const notification = (sessionId: string, turnId: string): GitHubTelemetryNotification => ({ + const notification = (sessionId: string, turnId: string, modelCallId?: string, initiatorType?: string): GitHubTelemetryNotification => ({ sessionId, restricted: false, event: { kind: 'response.success', - properties: { turnId }, + properties: { turnId, ...(modelCallId ? { modelCallId } : {}), ...(initiatorType ? { initiatorType } : {}) }, metrics: {}, }, }); - await forward(notification('active-session', 'runtime-active')); + await forward(notification('active-session', 'runtime-active', 'root-model-call', 'user')); + await forward(notification('active-session', 'runtime-subagent', 'subagent-model-call', 'agent')); + subagentCorrelation.complete('subagent-turn'); + await timeout(0); + await forward(notification('active-session', 'runtime-unresolved', 'unresolved-model-call', 'agent')); + await timeout(0); await forward(notification('second-active-session', 'runtime-second-active')); await forward(notification('active-session', 'runtime-active-again')); await forward(notification('idle-session', 'runtime-idle')); await forward(notification('unknown-session', 'runtime-unknown')); - assert.deepStrictEqual(telemetryService.events.map(event => { - const data = event.data as Record; - return event.eventName === 'agentHost.copilotClientStartup' - ? { eventName: event.eventName, outcome: data.outcome, durationMs: typeof data.durationMs, attemptNumber: data.attemptNumber } - : { eventName: event.eventName, sessionId: data.sdk_session_id, turnId: data.turnId }; - }), [ - { eventName: 'agentHost.copilotClientStartup', outcome: 'success', durationMs: 'number', attemptNumber: 1 }, - { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, - { eventName: 'copilotSdk/response.success', sessionId: 'second-active-session', turnId: 'turn-2' }, - { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, - { eventName: 'copilotSdk/response.success', sessionId: 'idle-session', turnId: undefined }, - { eventName: 'copilotSdk/response.success', sessionId: 'unknown-session', turnId: undefined }, - ]); + assert.deepStrictEqual({ + events: telemetryService.events.map(event => { + const data = event.data as Record; + return event.eventName === 'agentHost.copilotClientStartup' + ? { eventName: event.eventName, outcome: data.outcome, durationMs: typeof data.durationMs, attemptNumber: data.attemptNumber } + : { eventName: event.eventName, sessionId: data.sdk_session_id, turnId: data.turnId }; + }), + forwardedModelCallIds, + }, { + events: [ + { eventName: 'agentHost.copilotClientStartup', outcome: 'success', durationMs: 'number', attemptNumber: 1 }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'subagent-turn' }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: undefined }, + { eventName: 'copilotSdk/response.success', sessionId: 'second-active-session', turnId: 'turn-2' }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, + { eventName: 'copilotSdk/response.success', sessionId: 'idle-session', turnId: undefined }, + { eventName: 'copilotSdk/response.success', sessionId: 'unknown-session', turnId: undefined }, + ], + forwardedModelCallIds: ['root-model-call'], + }); } finally { await disposeAgent(agent); } diff --git a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts index 58431480993102..2a2475cc861ee8 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts @@ -50,7 +50,7 @@ suite('CopilotGitHubTelemetryForwarder', () => { kind: 'tool_call_executed', created_at: '2026-07-10T12:00:00Z', model_call_id: 'model-call', - properties: { tool_name: 'grep' }, + properties: { tool_name: 'grep', secondary_assignment_context: 'secondary:1' }, metrics: { duration_ms: 42 }, exp_assignment_context: 'experiment', features: { featureA: 'enabled' }, diff --git a/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts b/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts index b2999320c8d9b8..d4de07adf63503 100644 --- a/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts @@ -39,8 +39,8 @@ suite('CopilotSecondaryAssignmentContext', () => { context.update(notification('secondary:2')); assert.deepStrictEqual(telemetryService.experimentProperties, [ - { name: 'secondary_assignment_context', value: 'secondary:1' }, - { name: 'secondary_assignment_context', value: 'secondary:2' }, + { name: 'secondary.assignmentcontext', value: 'secondary:1' }, + { name: 'secondary.assignmentcontext', value: 'secondary:2' }, ]); }); @@ -52,7 +52,7 @@ suite('CopilotSecondaryAssignmentContext', () => { context.update(notification('secondary:1')); assert.deepStrictEqual(telemetryService.experimentProperties, [ - { name: 'secondary_assignment_context', value: 'secondary:1' }, + { name: 'secondary.assignmentcontext', value: 'secondary:1' }, ]); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 53364ca722aede..9fbab865eeedaf 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -489,7 +489,10 @@ suite('CopilotSlashCommandCompletionProvider', () => { { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, ]); const items = await run(provider, '/'); - assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/my-skill ']); + assert.deepStrictEqual(runtimeOnly(items).map(i => ({ + insertText: i.insertText, + isSkill: i.attachment?._meta?.isSkill, + })), [{ insertText: '/my-skill ', isSkill: true }]); }); test('excludes runtime skills that match a known plugin skill (with plugin prefix)', async () => { @@ -613,6 +616,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { type: MessageAttachmentKind.Simple, meta: { command: 'my-skill', + isSkill: true, description: 'Runtime skill', argumentHint: 'do stuff', }, diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 16813579b99f4a..b888346e968e7e 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -60,6 +60,10 @@ export class MockAgent implements IAgent { readonly onDidChangeChatData = Event.None; readonly onDidSpawnChat = Event.None; getTurnDiagnosticSnapshot?: IAgent['getTurnDiagnosticSnapshot']; + + recordModelCallTurnCorrelation(chat: URI, modelCallId: string, turnId: string): void { + this.modelCallTurnCorrelationCalls.push({ chat, modelCallId, turnId }); + } private readonly _onDidSendMessage = new Emitter(); readonly onDidSendMessage = this._onDidSendMessage.event; private readonly _models = observableValue(this, []); @@ -85,6 +89,7 @@ export class MockAgent implements IAgent { readonly setClientCustomizationsCalls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; readonly setClientToolsCalls: { clientId: string; tools: readonly ToolDefinition[] }[] = []; readonly removeActiveClientCalls: { chat: URI; clientId: string }[] = []; + readonly modelCallTurnCorrelationCalls: { chat: URI; modelCallId: string; turnId: string }[] = []; /** * Every host-supplied {@link IAgentChatContext} this agent was handed, * keyed by the boundary it arrived at. Lets shared tests assert that Agent diff --git a/src/vs/platform/agentHost/test/node/modelCallTurnCorrelation.test.ts b/src/vs/platform/agentHost/test/node/modelCallTurnCorrelation.test.ts new file mode 100644 index 00000000000000..17f24ed6a830bb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/modelCallTurnCorrelation.test.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ModelCallTurnCorrelation } from '../../node/copilot/modelCallTurnCorrelation.js'; + +suite('ModelCallTurnCorrelation', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns a correlation recorded before response telemetry', () => { + const correlation = new ModelCallTurnCorrelation(); + correlation.record('model-call-1', 'turn-1'); + + assert.deepStrictEqual({ + correlation: correlation.take('model-call-1'), + remaining: correlation.take('model-call-1'), + }, { + correlation: 'turn-1', + remaining: undefined, + }); + }); + + test('resolves response telemetry waiting for a correlation', async () => { + const correlation = new ModelCallTurnCorrelation(); + const pending = correlation.wait('model-call-1'); + + correlation.record('model-call-1', 'turn-1'); + + assert.deepStrictEqual({ + correlation: await pending, + remaining: correlation.take('model-call-1'), + }, { + correlation: 'turn-1', + remaining: undefined, + }); + }); + + test('discards correlations recorded after response fallback', async () => { + const correlation = new ModelCallTurnCorrelation({ timeoutMs: 0 }); + + correlation.markResponseForwarded('immediate-model-call'); + correlation.record('immediate-model-call', 'immediate-turn'); + const timedOut = await correlation.wait('timed-out-model-call'); + correlation.record('timed-out-model-call', 'late-turn'); + + assert.deepStrictEqual({ + immediate: correlation.take('immediate-model-call'), + timedOut, + late: correlation.take('timed-out-model-call'), + }, { + immediate: undefined, + timedOut: undefined, + late: undefined, + }); + }); + + test('bounds unmatched correlations and forwarded-response markers', () => { + const recordedCorrelations = new ModelCallTurnCorrelation({ cacheLimit: 2 }); + const forwardedCorrelations = new ModelCallTurnCorrelation({ cacheLimit: 2 }); + + for (let i = 1; i <= 3; i++) { + recordedCorrelations.record(`recorded-${i}`, `turn-${i}`); + forwardedCorrelations.markResponseForwarded(`forwarded-${i}`); + } + forwardedCorrelations.record('forwarded-1', 'late-turn'); + forwardedCorrelations.record('forwarded-3', 'discarded-turn'); + + assert.deepStrictEqual({ + evictedCorrelation: recordedCorrelations.take('recorded-1'), + retainedCorrelations: [recordedCorrelations.take('recorded-2'), recordedCorrelations.take('recorded-3')], + evictedMarkerAllowsCorrelation: forwardedCorrelations.take('forwarded-1'), + retainedMarkerDiscardsCorrelation: forwardedCorrelations.take('forwarded-3'), + }, { + evictedCorrelation: undefined, + retainedCorrelations: ['turn-2', 'turn-3'], + evictedMarkerAllowsCorrelation: 'late-turn', + retainedMarkerDiscardsCorrelation: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts b/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts index 6ca4513c22b52f..7e53c98b9ac889 100644 --- a/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts +++ b/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts @@ -48,9 +48,9 @@ suite('toolSearchDeferral', () => { } }); - test('temporarily rejects OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { + test('supports OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { for (const id of ['gpt-5.4', 'gpt-5.5', 'gpt-5-4', 'gpt-5-5', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { - assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); + assert.strictEqual(agentHostModelSupportsToolSearch(id), true, id); } }); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 80e2f1ebda2727..9a68ffb5b17f1f 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -15,7 +15,7 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, IObservable } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -54,6 +54,7 @@ import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/b import { AutomationIsolationModel, normalizeAutomationBranchNames } from '../common/isolationGroupModel.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from '../../chat/browser/mobile/mobileWorkspacePickerSheet.js'; +import { AutomationInputCompletions } from './automationInputCompletions.js'; const $ = DOM.$; @@ -67,7 +68,7 @@ const INTERVALS: { readonly value: AutomationInterval; readonly label: string }[ // Picker popups mount outside the dialog, so allow their focus targets through its focus trap. export function isAutomationDialogPopupTarget(relatedTarget: HTMLElement): boolean { return isMobilePickerSheetTarget(relatedTarget) || !!relatedTarget.closest( - '.context-view, .quick-input-widget, .monaco-menu-container, .monaco-hover, .monaco-hover-content' + '.context-view, .quick-input-widget, .monaco-menu-container, .monaco-hover, .monaco-hover-content, .suggest-widget' ); } @@ -998,6 +999,8 @@ export function renderForm( const promptRow = DOM.append(form, $('.automation-form-row')); DOM.append(promptRow, $('span.automation-form-label', undefined, localize('automation.form.prompt', "Prompt"))); const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); + const editorOverflowWidgetsDomNode = layoutService.getContainer(DOM.getWindow(promptHost)).appendChild($('.chat-editor-overflow.automation-dialog-editor-overflow.monaco-editor')); + disposables.add(toDisposable(() => editorOverflowWidgetsDomNode.remove())); const chatInputStyles: IChatInputStyles = { overlayBackground: 'var(--vscode-input-background)', @@ -1030,6 +1033,7 @@ export function renderForm( // reserve the default 24px margin and lay the editor out too narrow, // leaving its scrollbar floating ~24px in from the right wall. inputPartHorizontalPadding: 0, + editorOverflowWidgetsDomNode, sessionTypePickerDelegate: sessionTypeDelegate, secondaryToolbarOverflowActionHandler: (actionId, anchor) => { if (actionId === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { @@ -1115,6 +1119,7 @@ export function renderForm( ); chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); + disposables.add(scopedInstantiationService.createInstance(AutomationInputCompletions, chatInput.inputEditor)); if (initialMode) { const getUnfilteredInitialMode = () => { diff --git a/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts new file mode 100644 index 00000000000000..16f8d04077a194 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { IChatInputCompletionItem, IChatSessionsService, isAgentHostTarget } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { AgentHostInputCompletionsBase } from '../../../../workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export class AutomationInputCompletions extends AgentHostInputCompletionsBase { + + private readonly registration = this._register(new MutableDisposable()); + + constructor( + private readonly editor: ICodeEditor, + @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, + @IChatSessionsService chatSessionsService: IChatSessionsService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(languageFeaturesService, chatSessionsService); + + let currentScheme: string | undefined; + this._register(autorun(reader => { + const session = this.sessionsManagementService.automationSession.read(reader); + const scheme = session ? getChatSessionType(session.resource) : undefined; + if (scheme === currentScheme) { + return; + } + currentScheme = scheme; + this.registration.clear(); + if (scheme && isAgentHostTarget(scheme)) { + void this.registerForScheme(scheme); + } + })); + } + + private async registerForScheme(scheme: string): Promise { + const triggerCharacters = await this._chatSessionsService.getChatInputCompletionTriggerCharacters(scheme); + if (!triggerCharacters?.length) { + return; + } + + const session = this.sessionsManagementService.automationSession.get(); + const editorUri = this.editor.getModel()?.uri; + if (!session || getChatSessionType(session.resource) !== scheme || !editorUri) { + return; + } + + this.registration.value = this._registerProvider( + { scheme: editorUri.scheme, hasAccessToAllModels: true }, + `automationInputCompletions[${scheme}]`, + triggerCharacters, + scheme, + ); + } + + protected override _resolveContext(model: ITextModel, scheme: string): { sessionResource: URI; context: void } | undefined { + const session = this.sessionsManagementService.automationSession.get(); + if (model !== this.editor.getModel() || !session || getChatSessionType(session.resource) !== scheme) { + return undefined; + } + return { sessionResource: session.resource, context: undefined }; + } + + protected override _buildItem(position: Position, item: IChatInputCompletionItem): CompletionItem | undefined { + if (item.attachment.kind !== 'skill' && !(item.attachment.kind === 'command' && item.attachment.isSkill)) { + return undefined; + } + return { + label: { label: item.label ?? item.insertText, description: item.attachment.description }, + insertText: item.insertText, + filterText: item.insertText, + range: AutomationInputCompletions.computeRange(position, item), + documentation: item.attachment.description, + kind: CompletionItemKind.Text, + }; + } +} diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index 15cbeebce0f629..668f808f9e3b05 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -216,6 +216,10 @@ z-index: 2600 !important; } +.automation-dialog-open .automation-dialog-editor-overflow { + z-index: 2600; +} + .automation-form { display: flex; flex-direction: column; diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index 94896b8994dce6..cd78db7ee38241 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -885,12 +885,21 @@ suite('Automation branch picker', () => { }); }); - test('allows focus in mobile picker sheets', () => { + test('allows focus in popups rendered outside the dialog', () => { const sheet = document.createElement('div'); sheet.classList.add('mobile-picker-sheet'); - const item = sheet.appendChild(document.createElement('button')); + const sheetItem = sheet.appendChild(document.createElement('button')); + const suggestWidget = document.createElement('div'); + suggestWidget.classList.add('suggest-widget'); + const suggestion = suggestWidget.appendChild(document.createElement('div')); - assert.strictEqual(isAutomationDialogPopupTarget(item), true); + assert.deepStrictEqual({ + sheet: isAutomationDialogPopupTarget(sheetItem), + suggestion: isAutomationDialogPopupTarget(suggestion), + }, { + sheet: true, + suggestion: true, + }); }); test('resolves a legacy model identifier to the selected concrete target', () => { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts new file mode 100644 index 00000000000000..0045511fbf78ab --- /dev/null +++ b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.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 { timeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { CompletionItemKind, CompletionTriggerKind } from '../../../../../editor/common/languages.js'; +import { LanguageFeaturesService } from '../../../../../editor/common/services/languageFeaturesService.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { IChatInputCompletionsParams, IChatInputCompletionsResult, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { AutomationInputCompletions } from '../../browser/automationInputCompletions.js'; + +class TestChatSessionsService extends mock() { + override async getChatInputCompletionTriggerCharacters(): Promise { + return ['/']; + } + + override async provideChatInputCompletions(_sessionResource: URI, _params: IChatInputCompletionsParams, _token: CancellationToken): Promise { + return { + items: [ + { + insertText: '/review ', + start: { lineNumber: 1, column: 1 }, + end: { lineNumber: 1, column: 2 }, + attachment: { kind: 'skill', uri: URI.file('/skills/review/SKILL.md'), description: 'Review the workspace' }, + }, + { + insertText: '/plan ', + attachment: { kind: 'command', command: 'plan', description: 'Plan a task' }, + }, + { + insertText: '/runtime-skill ', + attachment: { kind: 'command', command: 'runtime-skill', isSkill: true, description: 'Run a runtime skill' }, + }, + ], + }; + } +} + +suite('AutomationInputCompletions', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows agent host skills for the automation draft session', async () => { + const languageFeaturesService = new LanguageFeaturesService(); + const model = store.add(createTextModel('/', null, undefined, URI.parse('vscode-chat-input:automation'))); + const editor = upcastPartial({ getModel: () => model }); + const session = upcastPartial({ + sessionId: 'automation', + resource: URI.parse('agent-host-copilot:automation'), + }); + const sessionsManagementService = upcastPartial({ + automationSession: constObservable(session), + }); + store.add(new AutomationInputCompletions(editor, languageFeaturesService, new TestChatSessionsService(), sessionsManagementService)); + await timeout(0); + + const provider = languageFeaturesService.completionProvider.ordered(model)[0]; + const result = await provider.provideCompletionItems( + model, + new Position(1, 2), + { triggerKind: CompletionTriggerKind.TriggerCharacter, triggerCharacter: '/' }, + CancellationToken.None, + ); + + assert.deepStrictEqual(result?.suggestions.map(item => ({ + label: item.label, + insertText: item.insertText, + filterText: item.filterText, + documentation: item.documentation, + kind: item.kind, + })), [ + { + label: { label: '/review ', description: 'Review the workspace' }, + insertText: '/review ', + filterText: '/review ', + documentation: 'Review the workspace', + kind: CompletionItemKind.Text, + }, + { + label: { label: '/runtime-skill ', description: 'Run a runtime skill' }, + insertText: '/runtime-skill ', + filterText: '/runtime-skill ', + documentation: 'Run a runtime skill', + kind: CompletionItemKind.Text, + }, + ]); + }); +}); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index e9298bdf3c9c08..817cf8b88574b3 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -84,7 +84,7 @@ flex-direction: column; } -.new-chat-input-area:focus-within { +.new-chat-input-area.focused { border-color: var(--vscode-agentsChatInput-focusBorder, var(--vscode-focusBorder)); } @@ -271,12 +271,35 @@ display: none; } -/* Expanded pickers remain intrinsic; the responsive controller switches them - * to compact form instead of allowing their labels to truncate. */ -.sessions-chat-config-toolbar .monaco-action-bar .action-item:not(.compact-picker) { +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker.model-picker-item { + width: auto; + min-width: min-content; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker.model-picker-item .action-label { + width: auto; + min-width: min-content; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker.model-picker-item .model-picker-config .chat-input-picker-label { + display: inline; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item:not(.compact-picker):not(.model-picker-item) { flex-shrink: 0; } +.sessions-chat-config-toolbar .monaco-action-bar .action-item.model-picker-item:not(.compact-picker) { + flex: 0 1 auto; + min-width: min-content; +} + +.sessions-chat-config-toolbar .action-item.model-picker-item .action-label.model-picker-split { + box-sizing: border-box; + width: 100%; + min-width: 0; +} + .sessions-chat-config-toolbar .action-label { display: flex; align-items: center; @@ -302,13 +325,16 @@ font-size: var(--vscode-fontSize-label2, 11px); } -/* Expanded labels are never truncated; compact mode removes the label. */ .sessions-chat-config-toolbar .action-label .chat-input-picker-label { margin-left: 4px; + white-space: nowrap; +} + +.sessions-chat-config-toolbar .action-label:not(.model-picker-split) .chat-input-picker-label, +.sessions-chat-config-toolbar .model-picker-config .chat-input-picker-label { flex-shrink: 0; overflow: visible; text-overflow: clip; - white-space: nowrap; } /* When the picker has no leading icon (e.g. model picker), drop the icon-to-label gap. */ diff --git a/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css b/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css index 9284ea76108bba..170c6b84b37cad 100644 --- a/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css +++ b/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css @@ -43,7 +43,7 @@ var(--vscode-cornerRadius-large, 8px); } -.new-chat-in-session .new-chat-input-area:focus-within { +.new-chat-in-session .new-chat-input-area.focused { border-color: var(--vscode-agentsChatInput-focusBorder, var(--vscode-focusBorder)) !important; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index dc774b56faf3aa..a8d058a7ed0b56 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -93,7 +93,7 @@ import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../workbench/ import { registerChatInputOnboardingHosts } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputOnboardingHosts.js'; import { IChatInputNoticeHubService } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHub.js'; import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; -import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refreshChatInputStack, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refreshChatInputStack, setChatInputStackInputFocused, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; @@ -883,6 +883,12 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // The composer is a chat input, so it carries the shared focus key that // chat input keybindings such as paste as text are scoped to. const inputHasFocusKey = ChatContextKeys.inputHasFocus.bindTo(inputScopedContextKeyService); + this._register(this._editor.onDidFocusEditorText(() => { + this._setInputEditorFocused(container, true); + })); + this._register(this._editor.onDidBlurEditorText(() => { + this._setInputEditorFocused(container, false); + })); this._register(this._editor.onDidFocusEditorWidget(() => { dictationFocusKey.set(true); inputHasFocusKey.set(true); @@ -997,6 +1003,11 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation })); } + private _setInputEditorFocused(container: HTMLElement, focused: boolean): void { + container.classList.toggle('focused', focused); + setChatInputStackInputFocused(container, focused); + } + /** * The input is monospace only while a terminal command is being composed: * the attached session advertises a prefix AND the current input begins with @@ -1122,6 +1133,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } items.push({ element, + canShrink: configToolbar.getItemAction(index)?.id === 'sessions.modelPicker', isCompact: () => element.classList.contains('compact-picker'), setCompact: (compact: boolean) => { element.classList.toggle('compact-picker', compact); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts index 31361f0c092d67..7f30a32c3d6ddb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts @@ -5,6 +5,9 @@ import { renderChatInput } from '../../../../../workbench/test/browser/componentFixtures/chat/renderChatInput.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ChatAgentLocation } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; // Loaded here (rather than in the workbench-layer fixture) so the // `.interactive-input-part` padding (32px each side) that the `isSessionsWindow` @@ -27,6 +30,42 @@ function sessionsWindowContext(context: ComponentFixtureContext): ComponentFixtu return { ...context, container: sessionsPart }; } +const responsiveModel: ILanguageModelChatMetadataAndIdentifier = { + identifier: 'openai-gpt-5.6-luna-responsive', + metadata: { + extension: new ExtensionIdentifier('fixture.extension'), + id: 'gpt-5.6-luna-responsive', + name: 'GPT-5.6 Luna Responsive Preview', + vendor: 'openai', + family: 'gpt', + version: '1', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: { [ChatAgentLocation.Chat]: true }, + configurationSchema: { + properties: { + effort: { + type: 'string', + group: 'navigation', + enum: ['low', 'medium', 'high'], + enumItemLabels: ['Low', 'Medium', 'Max 1M'], + default: 'high', + }, + }, + }, + }, +}; + +const responsiveCollapseWidths = [560, 500, 440, 380, 320, 260, 220, 180]; +const responsiveResizeCycles = [ + ...responsiveCollapseWidths, + ...[...responsiveCollapseWidths].reverse(), + ...responsiveCollapseWidths, + ...[...responsiveCollapseWidths].reverse(), + ...responsiveCollapseWidths, + ...[...responsiveCollapseWidths].reverse(), +]; + export default defineThemedFixtureGroup({ path: 'sessions/chat/input/' }, { SessionsWindow: defineComponentFixture({ render: context => renderChatInput(sessionsWindowContext(context), { @@ -44,4 +83,41 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/input/' }, { selection: { startLineNumber: 1, startColumn: 3, endLineNumber: 3, endColumn: 8 }, }) }), + ResponsiveModelResizeCycleExpanded: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + width: 600, + resizeWidths: [...responsiveResizeCycles, 600], + }) + }), + ResponsiveModelResizeCycleEllipsized: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + width: 600, + resizeWidths: [...responsiveResizeCycles, 380], + }) + }), + ResponsiveModelResizeCycleCompact: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + width: 600, + resizeWidths: [...responsiveResizeCycles, 320], + }) + }), + ResponsiveModelResizeCycleMinimal: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + voiceControl: 'voiceListening', + width: 600, + resizeWidths: [...responsiveResizeCycles, 260], + }) + }), }); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 3a8fed1ac50230..4d6fec7b7b1453 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -54,6 +54,30 @@ suite('Sessions - Chat View', () => { assert.strictEqual(dom.getWindow(label).getComputedStyle(label).display, 'none'); }); + test('keeps the model configuration label beside the compact model icon', () => { + const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); + disposables.add(toDisposable(() => toolbar.remove())); + const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.chat-input-picker-item.compact-picker.model-picker-item')); + const picker = dom.append(item, dom.$('.action-label.model-picker-split.compact')); + const name = dom.append(picker, dom.$('.model-picker-section.model-picker-name')); + name.style.minWidth = '22px'; + dom.append(name, dom.$('span.codicon')); + const config = dom.append(picker, dom.$('.model-picker-section.model-picker-config')); + const configLabel = dom.append(config, dom.$('span.chat-input-picker-label')); + configLabel.textContent = 'High'; + + assert.deepStrictEqual({ + configVisible: dom.getWindow(configLabel).getComputedStyle(configLabel).display !== 'none', + configWidth: config.getBoundingClientRect().width > 0, + nameWidth: name.getBoundingClientRect().width, + }, { + configVisible: true, + configWidth: true, + nameWidth: 22, + }); + }); + test('keeps compact empty-state picker icons inside their action item', () => { const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); disposables.add(toDisposable(() => toolbar.remove())); @@ -111,6 +135,49 @@ suite('Sessions - Chat View', () => { }); }); + test('keeps the voice toolbar visible when picker actions run out of space', () => { + const session = dom.append(document.body, dom.$('.interactive-session')); + disposables.add(toDisposable(() => session.remove())); + const toolbars = dom.append(session, dom.$('.chat-input-toolbars')); + toolbars.style.width = '180px'; + const inputToolbar = dom.append(toolbars, dom.$('.monaco-toolbar.responsive.chat-input-toolbar')); + inputToolbar.style.width = '240px'; + const executeToolbar = dom.append(toolbars, dom.$('.chat-execute-toolbar')); + executeToolbar.style.width = '70px'; + + assert.deepStrictEqual({ + inputWidth: inputToolbar.getBoundingClientRect().width, + executeWidth: executeToolbar.getBoundingClientRect().width, + executeEscapes: executeToolbar.getBoundingClientRect().right > toolbars.getBoundingClientRect().right, + }, { + inputWidth: 108, + executeWidth: 70, + executeEscapes: false, + }); + }); + + test('focuses the embedded composer frame only for editor focus', () => { + const workbench = dom.append(document.body, dom.$('.monaco-workbench')); + disposables.add(toDisposable(() => workbench.remove())); + workbench.style.setProperty('--vscode-agentsChatInput-border', 'rgb(255, 0, 0)'); + workbench.style.setProperty('--vscode-agentsChatInput-focusBorder', 'rgb(0, 255, 0)'); + const widget = dom.append(workbench, dom.$('.new-chat-in-session')); + const inputArea = dom.append(widget, dom.$('.new-chat-input-area')); + const picker = dom.append(inputArea, dom.$('button')); + + picker.focus(); + const pickerFocusedBorder = dom.getWindow(inputArea).getComputedStyle(inputArea).borderColor; + inputArea.classList.add('focused'); + + assert.deepStrictEqual({ + pickerFocusedBorder, + editorFocusedBorder: dom.getWindow(inputArea).getComputedStyle(inputArea).borderColor, + }, { + pickerFocusedBorder: 'rgb(255, 0, 0)', + editorFocusedBorder: 'rgb(0, 255, 0)', + }); + }); + test('does not forward aquarium visibility to the peer chat composer', () => { const isVisible = observableValue(disposables, true); const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index 4b2360ccbdf587..0a8deaa5de612f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -37,6 +37,7 @@ const clearDraftState = Reflect.get(NewChatInputWidget.prototype, '_clearDraftSt const updateDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateDraftState') as (this: IUpdateDraftStateHarness) => void; const updateAndSaveDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateAndSaveDraftState') as (this: IUpdateAndSaveDraftStateHarness) => void; const updateSendButtonState = Reflect.get(NewChatInputWidget.prototype, '_updateSendButtonState') as (this: IUpdateSendButtonStateHarness) => void; +const setInputEditorFocused = Reflect.get(NewChatInputWidget.prototype, '_setInputEditorFocused') as (container: HTMLElement, focused: boolean) => void; const updateAttachmentRendering = Reflect.get(NewChatContextAttachments.prototype, '_updateRendering') as (this: IAttachmentRenderingHarness) => void; interface IDraftStateHarness { @@ -125,6 +126,31 @@ class InputModelReferenceHarness implements IInputModelReferenceHarness, IDispos suite('NewChatInputWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('only keeps the input frame focused while editor text has focus', () => { + const stack = document.createElement('div'); + stack.classList.add('chat-input-stack'); + const inputArea = document.createElement('div'); + stack.appendChild(inputArea); + + setInputEditorFocused(inputArea, true); + const focused = { + input: inputArea.classList.contains('focused'), + stack: stack.classList.contains('chat-input-stack-input-focused'), + }; + setInputEditorFocused(inputArea, false); + + assert.deepStrictEqual({ + focused, + blurred: { + input: inputArea.classList.contains('focused'), + stack: stack.classList.contains('chat-input-stack-input-focused'), + }, + }, { + focused: { input: true, stack: true }, + blurred: { input: false, stack: false }, + }); + }); + test('keeps the input model alive until reference acquisition settles during disposal', async () => { const referenceDeferred = new DeferredPromise>(); let modelDisposed = false; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 3b019af428d316..63d6829065226e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -837,7 +837,9 @@ export class AgentHostSessionConfigPicker extends Disposable { return toActionItems(property, filteredItems, provider.getSessionConfig(sessionId)?.values[property] ?? schema.default, filteredPolicyRestricted, filteredRepositoryState.branchName, filteredRepositoryState.uncommittedChanges, onShowChanges); }) : undefined, - onHide: () => trigger.focus(), + onHide: () => { + trigger.focus(); + }, }; this._actionWidgetService.show( diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 2221a0a7450c56..c1a039b0e4a217 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { mock } from '../../../../../../../base/test/common/mock.js'; @@ -277,6 +278,33 @@ suite('Agent Host Session Config Picker', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('restores pointer and keyboard focus without leaving pointer focus visible', async () => { + const services = setupServices(store); + const { container } = renderPicker(store, services); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + const trigger = branchSlot(container)!.querySelector('a.action-label')!; + let focusCalls = 0; + trigger.focus = () => focusCalls++; + + trigger.click(); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate!.onHide(); + const pointerFocusCalls = focusCalls; + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate!.onHide(); + + assert.deepStrictEqual({ + pointerFocusCalls, + keyboardFocusCalls: focusCalls, + }, { + pointerFocusCalls: 1, + keyboardFocusCalls: 2, + }); + }); + test('places mode immediately before approvals in secondary toolbars', () => { const summarize = (menu: MenuId, ids: readonly string[]) => MenuRegistry.getMenuItems(menu) .filter(isIMenuItem) diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts index 2411b95643b525..37f7eac0b241ef 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts @@ -78,14 +78,16 @@ function setupPicker(store: Pick[] = []; let onSelect: ((item: IAgentHostSessionEnumPickerItem) => void) | undefined; + let onHide: (() => void) | undefined; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, - show: (_id: string, _supportsPreview: boolean, items: IActionListItem[], delegate: { onSelect: (item: T) => void }) => { + show: (_id: string, _supportsPreview: boolean, items: IActionListItem[], delegate: { onSelect: (item: T) => void; onHide: () => void }) => { actionWidgetItems.splice(0, actionWidgetItems.length, ...(items as IActionListItem[])); onSelect = delegate.onSelect as (item: IAgentHostSessionEnumPickerItem) => void; + onHide = delegate.onHide; }, }); const sessionObs = observableValue('activeSession', { providerId: PROVIDER_ID, sessionId: SESSION_ID } as IActiveSession); @@ -113,9 +115,10 @@ function setupPicker(store: Pick('a.action-label')?.click(); + const trigger = container.querySelector('a.action-label'); + trigger?.click(); - return { actionWidgetItems, openedResources, onSelect: () => onSelect, provider }; + return { actionWidgetItems, openedResources, onSelect: () => onSelect, onHide: () => onHide, provider, trigger }; } suite('AgentHostClaudePermissionModePicker', () => { @@ -145,6 +148,28 @@ suite('AgentHostClaudePermissionModePicker', () => { assert.strictEqual(new Set(iconIds).size, modeItems.length); }); + test('restores trigger focus after pointer and keyboard activation', () => { + const { onHide, trigger } = setupPicker(store); + let focusCalls = 0; + assert.ok(trigger); + trigger.focus = () => focusCalls++; + assert.ok(onHide()); + onHide()!(); + const pointerFocusCalls = focusCalls; + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.ok(onHide()); + onHide()!(); + + assert.deepStrictEqual({ + pointerFocusCalls, + keyboardFocusCalls: focusCalls, + }, { + pointerFocusCalls: 1, + keyboardFocusCalls: 2, + }); + }); + test('Learn More footer opens docs without writing session config', () => { const { actionWidgetItems, openedResources, onSelect, provider } = setupPicker(store); const learnMoreItem = actionWidgetItems.at(-1)?.item; diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts index 9188c06eea1ed9..96e0056fdc4dd7 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts @@ -47,11 +47,15 @@ export class MobilePermissionPicker extends PermissionPicker { } override showPicker(): void { + this._showPicker(); + } + + protected override _showPicker(): void { if (!this._triggerElement || this.actionWidgetService.isVisible || this._isResolving()) { return; } if (!isPhoneLayout(this._layoutService)) { - super.showPicker(); + super._showPicker(); return; } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts index ccf04379e50a62..79910f44eb6dca 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts @@ -254,7 +254,9 @@ export class ModePicker extends Disposable { this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, AICustomizationManagementSection.Agents); } }, - onHide: () => { triggerElement.focus(); }, + onHide: () => { + triggerElement.focus(); + }, }; this.actionWidgetService.show( diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index 17ef3412c0078f..f401ff7813d7a9 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -195,14 +195,14 @@ export class PermissionPicker extends Disposable { for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => { dom.EventHelper.stop(e, true); - this.showPicker(); + this._showPicker(); })); } this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { dom.EventHelper.stop(e, true); - this.showPicker(); + this._showPicker(); } })); @@ -258,6 +258,10 @@ export class PermissionPicker extends Disposable { } showPicker(): void { + this._showPicker(); + } + + protected _showPicker(): void { if (!this._triggerElement || this.actionWidgetService.isVisible || this._isResolving()) { return; } @@ -342,7 +346,9 @@ export class PermissionPicker extends Disposable { await this.openerService.open(URI.parse('https://aka.ms/vscode/docs/permissions')); } }, - onHide: () => { triggerElement.focus(); }, + onHide: () => { + triggerElement.focus(); + }, }; const listOptions: IActionListOptions = { minWidth: 255 }; diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts index 0ce79b7b3b13dd..4f108ac1e88c74 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { Event } from '../../../../../../base/common/event.js'; import { hash } from '../../../../../../base/common/hash.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; @@ -86,6 +86,7 @@ suite('ModePicker', () => { }()); let selectCustomAgent: (() => void) | undefined; + let hidePicker: (() => void) | undefined; const requestedChatResources: string[] = []; const picker = store.add(new ModePicker( model, @@ -103,6 +104,7 @@ suite('ModePicker', () => { assert.ok(item?.item); const modeItem = item.item; selectCustomAgent = () => delegate.onSelect(modeItem); + hidePicker = () => delegate.onHide(); } override hide(): void { } }(), @@ -124,14 +126,33 @@ suite('ModePicker', () => { }(), )); const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); picker.render(container); - container.querySelector('a.action-label')?.click(); + const trigger = container.querySelector('a.action-label'); + let focusCalls = 0; + if (trigger) { + trigger.focus = () => focusCalls++; + } + trigger?.click(); + assert.ok(hidePicker); + hidePicker(); + const pointerFocusCalls = focusCalls; + + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.ok(hidePicker); + hidePicker(); + const keyboardFocusCalls = focusCalls; + + trigger?.click(); assert.ok(selectCustomAgent); selectCustomAgent(); assert.deepStrictEqual({ events: telemetryService.events.filter(event => event.name === 'chat.modeChange'), requestedChatResources, + pointerFocusCalls, + keyboardFocusCalls, }, { events: [{ name: 'chat.modeChange', @@ -147,6 +168,8 @@ suite('ModePicker', () => { }, }], requestedChatResources: [chatResource.toString()], + pointerFocusCalls: 1, + keyboardFocusCalls: 2, }); }); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts index bf10e7d1008fc3..fe67cb3b06736d 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts @@ -4,12 +4,71 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IActionListDelegate, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; -import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta } from '../../browser/permissionPicker.js'; +import { TestStorageService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; +import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta, IPermissionPickerDelegate, PermissionPicker } from '../../browser/permissionPicker.js'; suite('Copilot PermissionPicker', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('restores trigger focus after pointer and keyboard activation', () => { + let onHide: (() => void) | undefined; + const actionWidgetService = new class extends mock() { + override readonly isVisible = false; + override show(_user: string, _supportsPreview: boolean, _items: readonly IActionListItem[], delegate: IActionListDelegate): void { + onHide = delegate.onHide; + } + override hide(): void { } + }(); + const delegate: IPermissionPickerDelegate = { + getPermissionLevelMeta: (_level, meta) => meta, + setPermissionLevel: () => { }, + }; + const picker = store.add(new PermissionPicker( + delegate, + actionWidgetService, + new TestConfigurationService(), + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestStorageService()), + NullTelemetryService, + new class extends mock() { + override setupDelayedHover() { return { dispose: () => { } }; } + }(), + )); + const container = document.createElement('div'); + picker.render(container); + const trigger = container.querySelector('a.action-label'); + assert.ok(trigger); + let focusCalls = 0; + trigger.focus = () => focusCalls++; + + trigger.click(); + assert.ok(onHide); + onHide(); + const pointerFocusCalls = focusCalls; + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.ok(onHide); + onHide(); + + assert.deepStrictEqual({ + pointerFocusCalls, + keyboardFocusCalls: focusCalls, + }, { + pointerFocusCalls: 1, + keyboardFocusCalls: 2, + }); + }); test('uses descriptions aligned with the agent host permission picker', () => { assert.deepStrictEqual(DEFAULT_PERMISSION_LEVELS.map(level => ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 6a36cfed66eb58..40fe4c9191d19c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1326,6 +1326,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return this._createCompletionItem(raw, text, { kind: 'command', command: completionMeta.command, + ...(completionMeta.isSkill ? { isSkill: true } : {}), description: completionMeta.description ?? '', ...(attachment._meta !== undefined && { _meta: attachment._meta }), }, attachment.label !== raw.insertText ? attachment.label : undefined); @@ -6811,7 +6812,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } resolveChatResponseUri(_sessionResource: URI, href: string, _kind: 'link' | 'image'): string { - return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority); + return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority, this._config.connection.resourceUris); } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 405479cde338eb..048f3e33f9c69a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -2126,7 +2126,7 @@ function normalizeFileUriSelection(uri: URI, href: string): URI { } /** Wraps an absolute path or internal URI target for the owning Agent Host connection. */ -export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string): string { +export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority)): string { let parsed = parseAbsoluteFileLinkTarget(href); if (!parsed) { try { @@ -2146,7 +2146,10 @@ export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: st let agentHostUri: URI; try { - agentHostUri = toAgentHostUri(parsed, connectionAuthority); + agentHostUri = resourceUris.fromAgentHost(parsed); + if (parsed.scheme !== Schemas.file && isEqual(agentHostUri, parsed)) { + agentHostUri = toAgentHostUri(parsed, connectionAuthority); + } } catch { return href; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts index fb796e975274a7..e047a9cc6107e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts @@ -9,6 +9,8 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { type MarkedExtension } from '../../../../../base/common/marked/marked.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRenderer, IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -124,6 +126,7 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer { @IConfigurationService configurationService: IConfigurationService, @IHoverService private readonly hoverService: IHoverService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @ILabelService private readonly labelService: ILabelService, ) { } render(markdown: IMarkdownString, options?: MarkdownRenderOptions, outElement?: HTMLElement): IRenderedMarkdown { @@ -162,7 +165,12 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer { // eslint-disable-next-line no-restricted-syntax result.element.querySelectorAll('a').forEach((element) => { if (element.title) { - const title = element.title; + let title = element.title; + if (title === element.dataset.href && title.startsWith(`${AGENT_HOST_SCHEME}:`)) { + const uri = URI.parse(title); + const label = this.labelService.getUriLabel(uri); + title = uri.fragment ? `${label}#${uri.fragment}` : label; + } element.title = ''; store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, title)); } 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 da2590b85fb0eb..291d98931c558b 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 @@ -27,7 +27,7 @@ display: inline-flex; align-items: center; line-height: 1.5em; - font-size: 13px; + font-size: var(--vscode-chat-font-size-body-m); &.chat-thinking-title-with-diff { padding-right: var(--vscode-spacing-size20); 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 bc07468326c781..7ad8f398f95ad9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -186,13 +186,18 @@ const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 1 const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2'; const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel'; -function getToolbarPickerResponsiveItems(toolbar: MenuWorkbenchToolBar, compactStates: ReadonlyMap>): IChatInputPickerResponsiveLayoutItem[] { +function getToolbarPickerResponsiveItems( + toolbar: MenuWorkbenchToolBar, + compactStates: ReadonlyMap>, + minimalStates?: ReadonlyMap>, +): IChatInputPickerResponsiveLayoutItem[] { const items: IChatInputPickerResponsiveLayoutItem[] = []; const visibleActionIds = new Set(); for (let index = 0; index < toolbar.getItemsLength(); index++) { const action = toolbar.getItemAction(index); const state = action && compactStates.get(action.id); + const minimalState = action && minimalStates?.get(action.id); const viewItem = toolbar.getItemViewItem(index); const viewItemState = isChatInputPickerResponsiveState(viewItem) ? viewItem : undefined; if (!action || (!state && !viewItemState)) { @@ -202,21 +207,27 @@ function getToolbarPickerResponsiveItems(toolbar: MenuWorkbenchToolBar, compactS const element = toolbar.getItemElement(index); items.push({ element, + canShrink: action.id === OpenModelPickerAction.ID, isCompact: () => viewItemState?.isCompact() ?? state!.get(), + isMinimal: minimalState ? () => minimalState.get() : undefined, setCompact: compact => { state?.set(compact, undefined); viewItemState?.setCompact(compact); element?.classList.toggle('compact-picker', compact); }, + setMinimal: minimalState ? minimal => minimalState.set(minimal, undefined) : undefined, }); } for (const [actionId, state] of compactStates) { if (!visibleActionIds.has(actionId)) { + const minimalState = minimalStates?.get(actionId); items.push({ element: undefined, isCompact: () => state.get(), + isMinimal: minimalState ? () => minimalState.get() : undefined, setCompact: compact => state.set(compact, undefined), + setMinimal: minimalState ? minimal => minimalState.set(minimal, undefined) : undefined, }); } } @@ -3445,6 +3456,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }); const inputPickerCompactStates = new Map>(); + const inputPickerMinimalStates = new Map>(); const secondaryPickerCompactStates = new Map>(); const inputOverflowPickerHandlers = new Map void>(); const secondaryOverflowPickerHandlers = new Map void>(); @@ -3460,6 +3472,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, compact: getCompactState(inputPickerCompactStates, actionId), + minimal: actionId === OpenModelPickerAction.ID ? getCompactState(inputPickerMinimalStates, actionId) : undefined, }); const getSecondaryPickerOptions = (actionId: string): IChatInputPickerOptions => ({ getOverflowAnchor: () => this.secondaryToolbar.getElement(), @@ -3522,6 +3535,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ConfigureToolsAction.ID, ]); const getInputActionMinWidth = (action: IAction): number | undefined => { + if (action.id === OpenModelPickerAction.ID) { + return this.modelWidget?.minimumWidth ?? 60; + } if (shorterChatInputActionIds.has(action.id)) { return 22; } @@ -3538,7 +3554,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge responsiveBehavior: { enabled: true, kind: 'last', - minItems: 1, + minItems: 2, actionMinWidth: 48, getActionMinWidth: getInputActionMinWidth, allowOverflow: () => this._inputPickerResponsiveLayout?.areAllItemsCompact() === true, @@ -3925,7 +3941,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const inputToolbarElement = this.inputActionsToolbar.getElement(); this._inputPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('ChatInputPart.primaryPicker', inputToolbarElement, { - getItems: () => getToolbarPickerResponsiveItems(this.inputActionsToolbar, inputPickerCompactStates), + getItems: () => getToolbarPickerResponsiveItems(this.inputActionsToolbar, inputPickerCompactStates, inputPickerMinimalStates), hasOverflow: () => this.inputActionsToolbar.hasOverflow(), relayout: () => this.inputActionsToolbar.relayout(), })); 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 d1c3aea1d5720a..cbbf97b58e92c3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -27,6 +27,8 @@ export interface IChatInputPickerOptions { readonly compact: IObservable; + readonly minimal?: IObservable; + readonly listOptions?: IActionListOptions; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts index 67f8044dd2e2a1..eba6955568cd6a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts @@ -17,10 +17,14 @@ export interface IChatInputPickerResponsiveLayoutDelegate { export interface IChatInputPickerResponsiveState { isCompact(): boolean; setCompact(compact: boolean): void; + isMinimal?(): boolean; + setMinimal?(minimal: boolean): void; } export interface IChatInputPickerResponsiveLayoutItem extends IChatInputPickerResponsiveState { readonly element: HTMLElement | undefined; + /** Let the item's CSS minimum width determine when its expanded form compacts. */ + readonly canShrink?: boolean; } export function isChatInputPickerResponsiveState(candidate: object | undefined): candidate is IChatInputPickerResponsiveState { @@ -68,29 +72,28 @@ export class ChatInputPickerResponsiveLayout extends Disposable { this._isLayouting = true; this._mutationObserver.disconnect(); try { - // Restore as many hidden actions as possible in their shortest form - // before measuring. Otherwise an overflow menu can hide the very items - // whose expanded width should keep the lane compact. - this._setAllCompact(true); - this._delegate.relayout?.(); - this._setAllCompact(true); - this._delegate.relayout?.(); - if (this._delegate.hasOverflow?.()) { + if (!this._restoreCompactItems()) { return; } const items = this._getOrderedVisibleItems(); for (const item of items) { + item.setMinimal?.(false); item.setCompact(false); } + this._delegate.relayout?.(); for (const item of items) { if (this._fitsAvailableWidth(availableWidth)) { break; } item.setCompact(true); + this._delegate.relayout?.(); + if (!this._fitsAvailableWidth(availableWidth)) { + item.setMinimal?.(true); + this._delegate.relayout?.(); + } } - this._delegate.relayout?.(); } finally { this._observeMutations(); this._isLayouting = false; @@ -98,12 +101,36 @@ export class ChatInputPickerResponsiveLayout extends Disposable { } areAllItemsCompact(): boolean { - return this._delegate.getItems().every(item => item.isCompact()); + return this._delegate.getItems().every(item => item.isCompact() && (!item.isMinimal || item.isMinimal())); } - private _setAllCompact(compact: boolean): void { + private _restoreCompactItems(): boolean { + if (!this._delegate.hasOverflow?.()) { + return true; + } + + let visibleItemCount = this._getOrderedVisibleItems().length; + while (true) { + this._setHiddenItemsCompact(); + this._delegate.relayout?.(); + if (!this._delegate.hasOverflow?.()) { + return true; + } + + const nextVisibleItemCount = this._getOrderedVisibleItems().length; + if (nextVisibleItemCount <= visibleItemCount) { + return false; + } + visibleItemCount = nextVisibleItemCount; + } + } + + private _setHiddenItemsCompact(): void { for (const item of this._delegate.getItems()) { - item.setCompact(compact); + if (!item.element?.isConnected) { + item.setCompact(true); + item.setMinimal?.(true); + } } } @@ -116,7 +143,7 @@ export class ChatInputPickerResponsiveLayout extends Disposable { private _fitsAvailableWidth(availableWidth: number): boolean { const items = this._getOrderedVisibleItems(); const preferredLayout = this._measurePreferredLayout(items); - if (preferredLayout.width > availableWidth + WIDTH_TOLERANCE) { + if (!items.some(item => item.canShrink) && preferredLayout.width > availableWidth + WIDTH_TOLERANCE) { return false; } @@ -133,7 +160,7 @@ export class ChatInputPickerResponsiveLayout extends Disposable { return false; } const preferredWidth = preferredLayout.itemWidths.get(item); - if (preferredWidth !== undefined && bounds.width < preferredWidth - WIDTH_TOLERANCE) { + if (!item.canShrink && preferredWidth !== undefined && bounds.width < preferredWidth - WIDTH_TOLERANCE) { return false; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index 601a7065c5f52c..be4d1807694a0c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -3,10 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.chat-input-picker-item:has(.model-picker-split) { +.chat-input-picker-item.model-picker-item { overflow: visible; } +.interactive-session .chat-input-toolbar .chat-input-picker-item.model-picker-item .action-label.model-picker-split { + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +.interactive-session .chat-input-toolbar.monaco-toolbar.responsive .monaco-action-bar > .actions-container > .action-item.model-picker-item { + flex-shrink: 1; +} + .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.model-picker-split { padding: 0; overflow: visible; @@ -51,8 +61,9 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name { - flex-shrink: 0; - overflow: visible; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; } .interactive-session .chat-input-toolbar .chat-input-picker-item.compact-picker .action-label.model-picker-split.compact { @@ -60,8 +71,29 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { - overflow: visible; - text-overflow: clip; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.interactive-session .chat-input-toolbars > .chat-input-toolbar .chat-input-picker-item.model-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-input-picker-item .action-label.model-picker-split.compact .model-picker-name { + flex: 0 0 auto; + padding: 0 var(--vscode-spacing-size60); + justify-content: flex-start; +} + +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.model-picker-split.icon-only.minimal .model-picker-name { + width: 24px; + padding: 0 var(--vscode-spacing-size60); + justify-content: flex-start; } .chat-input-picker-item .action-label.model-picker-split .model-picker-config { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index 75a3c1bad1aaf9..6f39c59144fab7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -17,6 +17,7 @@ import { localize } from '../../../../../../../nls.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../../common/languageModelSourcePresentation.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { IChatInputPickerOptions } from '../chatInputPickerActionItem.js'; import { ModelPickerWidget } from './modelPickerWidget.js'; @@ -84,6 +85,8 @@ export interface IModelPickerDelegate { export class ModelPickerActionItem extends BaseActionViewItem { private readonly _pickerWidget: ModelPickerWidget; private readonly _managedHover = this._register(new MutableDisposable()); + private _container: HTMLElement | undefined; + private _minimumWidth: number | undefined; constructor( action: IAction, @@ -98,6 +101,9 @@ export class ModelPickerActionItem extends BaseActionViewItem { this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget, delegate)); this._pickerWidget.setSelectedModel(delegate.currentModel.get()); this._pickerWidget.setCompact(pickerOptions.compact); + if (pickerOptions.minimal) { + this._pickerWidget.setMinimal(pickerOptions.minimal); + } // Sync delegate → widget when model list or selection changes externally this._register(autorun(t => { @@ -108,13 +114,28 @@ export class ModelPickerActionItem extends BaseActionViewItem { // Sync widget → delegate when user picks a model this._register(this._pickerWidget.onDidChangeSelection(model => delegate.setModel(model))); + this._register(this._pickerWidget.onDidChangeMinimumWidth(width => this._updateMinimumWidth(width))); } override render(container: HTMLElement): void { + this._container = container; this._pickerWidget.render(container); this.element = this._pickerWidget.domNode; this._updateTooltip(); - container.classList.add('chat-input-picker-item'); + container.classList.add('chat-input-picker-item', 'model-picker-item'); + this._updateMinimumWidth(this._pickerWidget.minimumWidth); + } + + get minimumWidth(): number { + return this._pickerWidget.minimumWidth; + } + + private _updateMinimumWidth(width: number): void { + if (!this._container || this._minimumWidth === width) { + return; + } + this._minimumWidth = width; + this._container.style.minWidth = `${width}px`; } private _getAnchorElement(): HTMLElement { @@ -191,7 +212,13 @@ export class ModelPickerActionItem extends BaseActionViewItem { if (this._pickerWidget.isSetupRequired()) { return localize('chat.modelPicker.setupRequiredHover', "{0} • Sign in to GitHub Copilot to choose a model.", label); } - const { statusIcon, tooltip } = this._pickerWidget.selectedModel?.metadata || {}; - return statusIcon && tooltip ? `${label} • ${tooltip}` : label; + const selectedModel = this._pickerWidget.selectedModel; + const { statusIcon, tooltip } = selectedModel?.metadata || {}; + if (selectedModel) { + label = localize('chat.modelPicker.selectedModelHover', "{0} • {1}", label, getLanguageModelDisplayNameWithSubscriptionSource(selectedModel)); + } + return statusIcon && tooltip + ? localize('chat.modelPicker.selectedModelStatusHover', "{0} • {1}", label, tooltip) + : label; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index 292dae4404e156..a9c91949385653 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -48,6 +48,10 @@ import { getCompactModelPickerIcon } from './modelProviderIcons.js'; import { getModelPickerUnavailableReason, isAutoModel, ModelPickerUnavailableReason, modelPickerRequiresSetup, shouldShowCacheBreakHint as computeShouldShowCacheBreakHint } from './modelPickerPresentation.js'; const CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY = 'chat.cacheBreakHintDismissed'; +const MODEL_PICKER_MINIMUM_LABEL_WIDTH = 60; +const MODEL_PICKER_NAME_CHROME_WIDTH = 30; +const MODEL_PICKER_MINIMUM_NAME_WIDTH = MODEL_PICKER_MINIMUM_LABEL_WIDTH + MODEL_PICKER_NAME_CHROME_WIDTH; +const MODEL_PICKER_COMPACT_NAME_WIDTH = 24; type ChatModelChangeClassification = { owner: 'lramos15'; comment: 'Reporting when the model picker is switched'; @@ -102,10 +106,13 @@ export class ModelPickerWidget extends Disposable { private readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; + private readonly _onDidChangeMinimumWidth = this._register(new Emitter()); + readonly onDidChangeMinimumWidth: Event = this._onDidChangeMinimumWidth.event; private _selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; private _badge: ModelPickerBadge | undefined; private _compact: IObservable | undefined; + private _minimal: IObservable | undefined; private _workspaceTrustInitialized = false; private _activatingAfterTrust = false; private readonly _activatingTimer = this._register(new MutableDisposable()); @@ -114,6 +121,7 @@ export class ModelPickerWidget extends Disposable { private _badgeIcon: HTMLElement | undefined; private _nameButton: HTMLElement | undefined; private _configButton: HTMLElement | undefined; + private _minimumWidth = MODEL_PICKER_MINIMUM_NAME_WIDTH; private readonly _configuration: ModelPickerConfiguration; get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { @@ -128,6 +136,18 @@ export class ModelPickerWidget extends Disposable { return this._nameButton; } + get minimumWidth(): number { + return this._minimumWidth; + } + + private _updateMinimumWidth(nameWidth: number): void { + const minimumWidth = nameWidth + (this._configButton?.offsetWidth ?? 0); + if (this._minimumWidth !== minimumWidth) { + this._minimumWidth = minimumWidth; + this._onDidChangeMinimumWidth.fire(minimumWidth); + } + } + constructor( private readonly _delegate: IModelPickerDelegate, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @@ -220,6 +240,15 @@ export class ModelPickerWidget extends Disposable { })); } + setMinimal(minimal: IObservable): void { + this._minimal = minimal; + this._register(autorun(reader => { + const isMinimal = minimal.read(reader); + this._domNode?.classList.toggle('minimal', isMinimal); + this._renderLabel(); + })); + } + setSelectedModel(model: ILanguageModelChatMetadataAndIdentifier | undefined): void { this._selectedModel = model; this._renderLabel(); @@ -312,6 +341,9 @@ export class ModelPickerWidget extends Disposable { if (this._compact?.get()) { this._domNode.classList.toggle('compact', true); } + if (this._minimal?.get()) { + this._domNode.classList.toggle('minimal', true); + } // Model name button this._nameButton = dom.append(this._domNode, dom.$('a.model-picker-section.model-picker-name')); @@ -595,6 +627,7 @@ export class ModelPickerWidget extends Disposable { : this._selectedModel.metadata.statusIcon ? getCompactCodicon(this._selectedModel.metadata.statusIcon) : undefined) : undefined; const compact = this._compact?.get() ?? false; + const minimal = this._minimal?.get() ?? false; if (modelIcon && !noModelsAvailable) { nameChildren.push(renderIcon(modelIcon)); } @@ -610,6 +643,8 @@ export class ModelPickerWidget extends Disposable { ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); const showModelLabel = !compact || !modelIcon || noModelsAvailable; + const nameMinimumWidth = compact && !showModelLabel ? MODEL_PICKER_COMPACT_NAME_WIDTH : MODEL_PICKER_MINIMUM_NAME_WIDTH; + this._nameButton.style.minWidth = `${nameMinimumWidth}px`; if (showModelLabel) { nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); } @@ -618,11 +653,11 @@ export class ModelPickerWidget extends Disposable { } dom.reset(this._nameButton, ...nameChildren); - this._domNode.classList.toggle('icon-only', !showModelLabel); - if (this._configButton) { - this._configuration.renderButton(this._configButton, compact, noModelsAvailable); + this._configuration.renderButton(this._configButton, minimal, noModelsAvailable); } + const configVisible = !!this._configButton && this._configButton.style.display !== 'none'; + this._domNode.classList.toggle('icon-only', !showModelLabel && !configVisible); // Aria — name the control "Models" to match the visible label; the comma // separates the control name from its current value / state. @@ -633,6 +668,7 @@ export class ModelPickerWidget extends Disposable { : localize('chat.modelPicker.ariaLabel', "Models, {0}", modelLabel); this._domNode.ariaLabel = ariaLabel; this._nameButton.ariaLabel = ariaLabel; + this._updateMinimumWidth(nameMinimumWidth); } } 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 cb18b7ecf09885..b7bcf2da95e27d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1348,6 +1348,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars > .chat-execute-toolbar { order: 2; + flex-shrink: 0; } .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .actions-container { @@ -1951,7 +1952,7 @@ have to be updated for changes to the rules above, or to support more deeply nes order: 0; overflow: hidden; min-width: 0px; - width: 100%; + flex: 1 1 0; color: var(--vscode-icon-foreground); .monaco-action-bar .action-item .codicon { @@ -3216,7 +3217,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-used-context-label { - font-size: var(--vscode-fontSize-body1); + font-size: var(--vscode-chat-font-size-body-m); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); user-select: none; @@ -3339,7 +3340,7 @@ have to be updated for changes to the rules above, or to support more deeply nes align-items: flex-start; gap: 4px; margin: 0 0 var(--vscode-spacing-size160) 0; - font-size: var(--vscode-fontSize-body1); + font-size: var(--vscode-chat-font-size-body-m); /* Tool calls transition from a progress to a collapsible list part, which needs to have this top padding. The working progress also can be replaced by a tool progress part. So align this padding so the text doesn't appear to shift. */ diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 680e810082a1d2..c692ca4e4c2ecf 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -562,6 +562,7 @@ export interface IChatInputCompletionResourceAttachment { export interface IChatInputCompletionCommandAttachment { readonly kind: 'command'; readonly command: string; + readonly isSkill?: true; readonly description: string; /** * Implementation-defined metadata that MUST be preserved by the diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index b471cf3b72e1aa..961659b195f34f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -26,7 +26,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; -import { createAgentHostResourceUriMapper, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; @@ -1271,6 +1271,90 @@ suite('AgentHostChatContribution', () => { }); + suite('response resource links', () => { + test('uses the WSL connection for file links despite a local session authority', () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = createAgentHostResourceUriMapper(authority); + const session = URI.parse('agent-host-copilot:/session'); + const file = URI.file('/home/user/project/src/file.ts').with({ fragment: 'L42,7' }); + const targets = [ + '/home/user/project/src/file.ts:42:7', + 'file:///home/user/project/src/file.ts#L42,7', + ]; + + assert.deepStrictEqual(targets.map(href => { + const resolved = URI.parse(sessionHandler.resolveChatResponseUri(session, href, 'link')); + return { resolved: resolved.toString(), hostUri: fromAgentHostUri(resolved).toString() }; + }), targets.map(() => ({ + resolved: toAgentHostUri(file, authority).toString(), + hostUri: file.toString(), + }))); + }); + + test('uses the WSL connection for image paths and preserves encoded path characters', () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = createAgentHostResourceUriMapper(authority); + const session = URI.parse('agent-host-copilot:/session'); + const image = URI.file('/home/user/my project/image.png'); + + assert.strictEqual( + sessionHandler.resolveChatResponseUri(session, '/home/user/my%20project/image.png', 'image'), + toAgentHostUri(image, authority).toString(), + ); + }); + + for (const host of ['local', 'WSL']) { + test(`routes ${host} internal resource links and images through the owning Agent Host`, () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const authority = host === 'local' ? 'local' : agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = host === 'local' ? identityAgentHostResourceUriMapper : createAgentHostResourceUriMapper(authority); + const session = URI.parse('agent-host-copilot:/session'); + const resources = [ + URI.parse('agenthost-content:///session/my%20result.txt?view=raw#L42,7'), + URI.parse('git-blob:///project/src/file.ts?ref=HEAD#L7'), + ]; + + assert.deepStrictEqual(resources.map(resource => { + const href = resource.toString(); + const link = sessionHandler.resolveChatResponseUri(session, href, 'link'); + return { + link, + image: sessionHandler.resolveChatResponseUri(session, href, 'image'), + unwrapped: fromAgentHostUri(URI.parse(link)).toString(), + alreadyMapped: sessionHandler.resolveChatResponseUri(session, toAgentHostUri(resource, authority).toString(), 'link'), + }; + }), resources.map(resource => ({ + link: toAgentHostUri(resource, authority).toString(), + image: toAgentHostUri(resource, authority).toString(), + unwrapped: resource.toString(), + alreadyMapped: toAgentHostUri(resource, authority).toString(), + }))); + }); + } + + test('preserves local file links and external or already mapped links', () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const session = URI.parse('agent-host-copilot:/session'); + const local = sessionHandler.resolveChatResponseUri(session, '/project/file.ts:42', 'link'); + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = createAgentHostResourceUriMapper(authority); + const mapped = toAgentHostUri(URI.file('/home/user/file.ts'), authority).toString(); + const external = 'https://example.com/file.ts'; + + assert.deepStrictEqual({ + local, + mapped: sessionHandler.resolveChatResponseUri(session, mapped, 'link'), + external: sessionHandler.resolveChatResponseUri(session, external, 'link'), + }, { + local: URI.file('/project/file.ts').with({ fragment: 'L42' }).toString(), + mapped, + external, + }); + }); + }); + // ---- Download progress notification (editor window) ----------------- suite('download progress', () => { @@ -14351,6 +14435,43 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(result?.items[0].insertText, ''); }); + test('preserves the runtime skill distinction on command attachments', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + (agentHostService as unknown as { completions: (p: CompletionsParams) => Promise }).completions = async () => ({ + items: [{ + insertText: '/runtime-skill ', + attachment: { + type: MessageAttachmentKind.Simple, + label: '/runtime-skill ', + _meta: { + command: 'runtime-skill', + isSkill: true, + description: 'Run a runtime skill', + }, + }, + }], + }); + + const result = await sessionHandler.provideChatInputCompletions( + URI.from({ scheme: 'agent-host-copilot', path: '/abc' }), + { text: '/', offset: 1 }, + CancellationToken.None, + ); + + assert.deepStrictEqual(result?.items[0].attachment, { + kind: 'command', + command: 'runtime-skill', + isSkill: true, + description: 'Run a runtime skill', + _meta: { + command: 'runtime-skill', + isSkill: true, + description: 'Run a runtime skill', + }, + }); + }); + test('routes untitled completions to the current opaque provisional backend', async () => { const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-completions' }); const backendSession = AgentSession.uri('copilot', 'opaque-provisional'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts index ff7dea639c7f6c..14986b6d0a27d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts @@ -4,9 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AGENT_HOST_LABEL_FORMATTER, agentHostAuthority, agentHostLabelFormatter, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { NullHoverService } from '../../../../../../platform/hover/test/browser/nullHoverService.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ChatContentMarkdownRenderer } from '../../../browser/widget/chatContentMarkdownRenderer.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; @@ -14,11 +21,76 @@ suite('ChatMarkdownRenderer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let testRenderer: ChatContentMarkdownRenderer; + let instantiationService: ReturnType; setup(() => { - const instantiationService = store.add(workbenchInstantiationService(undefined, store)); + instantiationService = store.add(workbenchInstantiationService(undefined, store)); testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer); }); + suite('link hovers', () => { + let setupManagedHover: sinon.SinonSpy, ReturnType>; + + setup(() => { + setupManagedHover = sinon.spy(NullHoverService.setupManagedHover); + instantiationService.stub(IHoverService, { ...NullHoverService, setupManagedHover }); + store.add(instantiationService.get(ILabelService).registerFormatter(AGENT_HOST_LABEL_FORMATTER)); + testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer); + }); + + test('shows host paths for transformed and already mapped links without changing their targets', () => { + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + store.add(instantiationService.get(ILabelService).registerFormatter(agentHostLabelFormatter(authority, OperatingSystem.Linux))); + const file = URI.file('/home/user/my project/a&b.ts').with({ fragment: 'L42,7' }); + const target = toAgentHostUri(file, authority).toString(); + const links = [ + { href: '/home/user/my%20project/a&b.ts:42:7', transformUri: () => target }, + { href: target, transformUri: undefined }, + ]; + + const actual = links.map(({ href, transformUri }) => { + const result = store.add(testRenderer.render(new MarkdownString(`[file](${href})`), { transformUri })); + const link = result.element.querySelector('a'); + return { + hover: setupManagedHover.lastCall.args[2], + target: link?.dataset.href, + text: link?.textContent, + nativeTitle: link?.title, + }; + }); + + assert.deepStrictEqual(actual, links.map(() => ({ + hover: '/home/user/my project/a&b.ts#L42,7', + target, + text: 'file', + nativeTitle: '', + }))); + }); + + test('uses the remote host operating system for path formatting', () => { + const labelService = instantiationService.get(ILabelService); + store.add(labelService.registerFormatter(agentHostLabelFormatter('windows-host', OperatingSystem.Windows))); + const target = toAgentHostUri(URI.file('C:/my project/file.ts'), 'windows-host').toString(); + store.add(testRenderer.render(new MarkdownString(`[file](${target})`))); + + assert.strictEqual(setupManagedHover.lastCall.args[2], 'C:\\my project\\file.ts'); + }); + + test('preserves explicit titles and ordinary file, external, and command link behavior', () => { + const target = toAgentHostUri(URI.file('/home/user/file.ts'), 'remote-host').toString(); + const file = URI.file('/my project/file.ts').with({ fragment: 'L7' }); + const markdown = new MarkdownString(`[custom](${target} "Custom title") [file](${file}) [web](https://example.com/) [command](command:example)`, { isTrusted: true }); + const result = store.add(testRenderer.render(markdown)); + + assert.deepStrictEqual({ + hovers: setupManagedHover.getCalls().map(call => call.args[2]), + targets: Array.from(result.element.querySelectorAll('a'), link => link.dataset.href), + }, { + hovers: ['Custom title', `${file.fsPath}#L7`, 'https://example.com/'], + targets: [target, file.toString(), 'https://example.com/', 'command:example'], + }); + }); + }); + test('simple', async () => { const md = new MarkdownString('a'); const result = store.add(testRenderer.render(md)); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 69609fa4312a40..166f46374b43ee 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -610,8 +610,18 @@ suite('ChatPetWidget', () => { mainWindow.dispatchEvent(new FocusEvent('blur')); const hiddenAfterExternalBlur = button.classList.contains('hidden'); + const windowTransferred = new Promise(resolve => { + const observer = new mainWindow.MutationObserver(() => { + if (button.classList.contains('hidden')) { + observer.disconnect(); + resolve(); + } + }); + disposables.add(toDisposable(() => observer.disconnect())); + observer.observe(button, { attributes: true, attributeFilter: ['class'] }); + }); ownershipChannel.postMessage({ windowId: mainWindow.vscodeWindowId + 1 }); - await new Promise(resolve => mainWindow.setTimeout(resolve, 10)); + await windowTransferred; const hiddenAfterWindowTransfer = button.classList.contains('hidden'); mainWindow.dispatchEvent(new FocusEvent('focus')); const hiddenAfterReturn = button.classList.contains('hidden'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts index 1950c44e45a277..2a191963c2c771 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts @@ -154,12 +154,12 @@ suite('ChatInputPickerResponsiveLayout', () => { lane.style.overflow = 'hidden'; const actionBar = dom.append(lane, dom.$('.monaco-action-bar.has-overflow')); - const picker = dom.append(actionBar, dom.$('.picker')); + const picker = dom.$('.picker'); let compact = false; let overflow = true; const layout = store.add(new ChatInputPickerResponsiveLayout('test.overflowedPickerLane', lane, { getItems: () => [{ - element: picker, + element: picker.isConnected ? picker : undefined, isCompact: () => compact, setCompact: value => { compact = value; @@ -168,7 +168,10 @@ suite('ChatInputPickerResponsiveLayout', () => { }], hasOverflow: () => overflow, relayout: () => { - overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + if (!picker.isConnected && compact && Number.parseFloat(picker.style.width) <= lane.getBoundingClientRect().width) { + actionBar.appendChild(picker); + overflow = false; + } }, })); @@ -190,6 +193,59 @@ suite('ChatInputPickerResponsiveLayout', () => { }); }); + test('restores every hidden picker across repeated overflow cycles', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '400px'; + + const compact = [true, true, true, true]; + const pickers = compact.map((_, index) => { + const picker = dom.$(`.picker-${index}`); + picker.style.width = '20px'; + return picker; + }); + let visibleItemCount = 1; + let overflow = true; + lane.appendChild(pickers[0]); + + const hidePickers = () => { + for (const picker of pickers.slice(1)) { + picker.remove(); + } + visibleItemCount = 1; + overflow = true; + }; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.restoreAllPickerLane', lane, { + getItems: () => pickers.map((picker, index) => ({ + element: picker, + isCompact: () => compact[index], + setCompact: value => { + compact[index] = value; + picker.style.width = value ? '20px' : '80px'; + }, + })), + hasOverflow: () => overflow, + relayout: () => { + if (visibleItemCount < pickers.length && compact.slice(visibleItemCount).every(Boolean)) { + lane.appendChild(pickers[visibleItemCount++]); + } + overflow = visibleItemCount < pickers.length; + }, + })); + + layout.layout(); + const firstRestore = { visibleItemCount, compact: [...compact], overflow }; + + hidePickers(); + layout.layout(); + const secondRestore = { visibleItemCount, compact: [...compact], overflow }; + + assert.deepStrictEqual({ firstRestore, secondRestore }, { + firstRestore: { visibleItemCount: 4, compact: [false, false, false, false], overflow: false }, + secondRestore: { visibleItemCount: 4, compact: [false, false, false, false], overflow: false }, + }); + }); + test('compacts a picker whose rendered bounds escape the lane', () => { const lane = dom.append(host, dom.$('.picker-lane')); lane.style.display = 'flex'; @@ -255,6 +311,96 @@ suite('ChatInputPickerResponsiveLayout', () => { assert.strictEqual(compact, true); }); + test('lets a shrinkable picker ellipsize before compacting at its minimum width', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '100px'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 1 160px'; + picker.style.width = '160px'; + picker.style.minWidth = '60px'; + picker.style.overflow = 'hidden'; + const label = dom.append(picker, dom.$('.picker-label')); + label.style.overflow = 'hidden'; + label.style.textOverflow = 'ellipsis'; + label.style.whiteSpace = 'nowrap'; + label.textContent = 'A picker label that can ellipsize'; + + let compact = false; + let overflow = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.shrinkablePickerLane', lane, { + getItems: () => [{ + element: picker, + canShrink: true, + isCompact: () => compact, + setCompact: value => { + compact = value; + }, + }], + hasOverflow: () => overflow, + relayout: () => { + picker.style.flexBasis = compact ? '20px' : '160px'; + picker.style.width = compact ? '20px' : '160px'; + picker.style.minWidth = compact ? '20px' : '60px'; + overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + }, + })); + + layout.layout(); + const truncated = { compact, overflow, width: picker.getBoundingClientRect().width }; + + lane.style.width = '50px'; + layout.layout(); + const collapsed = { compact, overflow, width: picker.getBoundingClientRect().width }; + + lane.style.width = '15px'; + layout.layout(); + const overflowed = { compact, overflow }; + + assert.deepStrictEqual({ truncated, collapsed, overflowed }, { + truncated: { compact: false, overflow: false, width: 100 }, + collapsed: { compact: true, overflow: false, width: 20 }, + overflowed: { compact: true, overflow: true }, + }); + }); + + test('uses a minimal picker state before overflowing', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '50px'; + + const picker = dom.append(lane, dom.$('.picker')); + let compact = false; + let minimal = false; + let overflow = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.minimalPickerLane', lane, { + getItems: () => [{ + element: picker, + canShrink: true, + isCompact: () => compact, + isMinimal: () => minimal, + setCompact: value => compact = value, + setMinimal: value => minimal = value, + }], + hasOverflow: () => overflow, + relayout: () => { + picker.style.width = minimal ? '20px' : compact ? '60px' : '120px'; + picker.style.minWidth = minimal ? '20px' : '60px'; + overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + }, + })); + + layout.layout(); + + assert.deepStrictEqual({ compact, minimal, overflow, width: picker.getBoundingClientRect().width }, { + compact: true, + minimal: true, + overflow: false, + width: 20, + }); + }); + test('keeps the toolbar row height stable when the model picker overflows', () => { host.style.setProperty('--vscode-spacing-size40', '4px'); host.style.setProperty('--vscode-spacing-size60', '6px'); @@ -264,10 +410,15 @@ suite('ChatInputPickerResponsiveLayout', () => { row.style.display = 'flex'; row.style.alignItems = 'center'; - const modelItem = dom.append(row, dom.$('.chat-input-picker-item')); + const modelItem = dom.append(row, dom.$('.chat-input-picker-item.model-picker-item')); + modelItem.style.width = '100px'; const modelLabel = dom.append(modelItem, dom.$('a.action-label.model-picker-split')); const modelName = dom.append(modelLabel, dom.$('.model-picker-section.model-picker-name')); + modelName.style.minWidth = '90px'; const pickerLabel = dom.append(modelName, dom.$('.chat-input-picker-label')); + pickerLabel.textContent = 'A very long model name'; + const modelConfig = dom.append(modelLabel, dom.$('.model-picker-section.model-picker-config')); + modelConfig.style.width = '40px'; const overflowItem = dom.append(row, dom.$('.overflow-item')); overflowItem.style.width = '22px'; @@ -275,6 +426,11 @@ suite('ChatInputPickerResponsiveLayout', () => { overflowItem.style.display = 'none'; const withModelPicker = row.getBoundingClientRect().height; + const expandedModelNameFlexShrink = dom.getWindow(modelName).getComputedStyle(modelName).flexShrink; + const expandedLabelTextOverflow = dom.getWindow(pickerLabel).getComputedStyle(pickerLabel).textOverflow; + const expandedModelPickerWidth = modelLabel.getBoundingClientRect().width; + const expandedModelNameWidth = modelName.getBoundingClientRect().width; + const expandedLabelTruncated = pickerLabel.scrollWidth > pickerLabel.clientWidth; const expandedIconOffset = modelName.getBoundingClientRect().left - modelLabel.getBoundingClientRect().left; modelLabel.style.width = '22px'; modelItem.classList.add('compact-picker'); @@ -287,15 +443,21 @@ suite('ChatInputPickerResponsiveLayout', () => { assert.deepStrictEqual({ withModelPicker, withOverflow, - modelNameFlexShrink: dom.getWindow(modelName).getComputedStyle(modelName).flexShrink, - labelTextOverflow: dom.getWindow(pickerLabel).getComputedStyle(pickerLabel).textOverflow, + expandedModelNameFlexShrink, + expandedLabelTextOverflow, + expandedModelPickerWidth, + expandedModelNameWidth, + expandedLabelTruncated, expandedIconOffset, compactIconOffset, }, { withModelPicker: 22, withOverflow: 22, - modelNameFlexShrink: '0', - labelTextOverflow: 'clip', + expandedModelNameFlexShrink: '1', + expandedLabelTextOverflow: 'ellipsis', + expandedModelPickerWidth: 100, + expandedModelNameWidth: 90, + expandedLabelTruncated: true, expandedIconOffset: 0, compactIconOffset: 0, }); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index e2e6c3483c0080..36b9667d6b400b 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -341,7 +341,6 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private readonly initPromise: Promise; private readonly updateThrottler = this._register(new ThrottledDelayer(100)); private readonly accountDataPollScheduler = this._register(new RunOnceScheduler(() => this.refetchDefaultAccount(), ACCOUNT_DATA_POLL_INTERVAL_MS)); - private readonly managedSettingsFetchAttemptedAccounts = new Set(); private readonly failedManagedSettingsFreshness = new Map(); constructor( @@ -443,10 +442,22 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun if (e.providerId !== defaultAccountProvider.id) { return; } - if (this.defaultAccount && e.event.removed?.some(session => session.id === this.defaultAccount?.sessionId)) { + // Providers can atomically replace a session, so only removal-only events represent an immediate sign-out. + const added = e.event.added?.length ?? 0; + const removed = e.event.removed?.length ?? 0; + const changed = e.event.changed?.length ?? 0; + const currentSessionRemoved = this.defaultAccount !== null && (e.event.removed?.some(session => session.id === this.defaultAccount?.sessionId) ?? false); + const hasReplacementCandidates = added > 0 || changed > 0; + const clearDefaultAccount = currentSessionRemoved && !hasReplacementCandidates; + const message = `[DefaultAccount] Authentication sessions changed: added=${added}, removed=${removed}, changed=${changed}, currentSessionRemoved=${currentSessionRemoved}, hasReplacementCandidates=${hasReplacementCandidates}, decision=${clearDefaultAccount ? 'clear' : 'reconcile'}`; + if (currentSessionRemoved || (added > 0 && removed > 0)) { + this.logService.info(message); + } else { + this.logService.trace(message); + } + if (clearDefaultAccount) { this.setDefaultAccount(null); } else { - this.logService.debug('[DefaultAccount] Sessions changed for default account provider, updating default account'); this.updateDefaultAccount(); } })); @@ -561,8 +572,13 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun } private async doUpdateDefaultAccount(options?: IDefaultAccountRefreshOptions): Promise { + const currentSessionId = this.defaultAccount?.sessionId; try { const defaultAccount = await this.fetchDefaultAccount(options); + if (currentSessionId && this.defaultAccount?.sessionId !== currentSessionId) { + this.logService.info('[DefaultAccount] Discarding default account update because the current session changed while the update was in progress'); + return; + } this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { @@ -1130,6 +1146,13 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun } const scope = this.createManagedSettingsFreshnessScope(accountId, authenticationProvider.id, managedSettingsUrl); + const cachedScope = accountPolicyData?.managedSettingsScope; + // Only reuse a cache captured for the current provider and endpoint (a legacy cache with no recorded + // scope is trusted), so a previous GitHub Enterprise host's policy is not applied after a scope switch. + const cacheScopeMatches = !cachedScope || this.getManagedSettingsScopeKey(cachedScope) === this.getManagedSettingsScopeKey(scope); + const scopedManagedSettings = cacheScopeMatches ? accountPolicyData?.policyData.managedSettings : undefined; + const scopedManagedSettingsFetchedAt = cacheScopeMatches ? accountPolicyData?.managedSettingsFetchedAt : undefined; + const scopedCachedManagedSettings = cacheScopeMatches ? cachedManagedSettings : undefined; if (requirement.effective && !this.canRequestManagedSettings(options, scope)) { this.logService.debug('[DefaultAccount] Skipping automatic managed settings retry after a prior failure'); const failedFreshness = this.failedManagedSettingsFreshness.get(this.getManagedSettingsScopeKey(scope)); @@ -1137,18 +1160,18 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.setManagedSettingsFreshness({ ...failedFreshness, source: requirement.source }); } return { - data: { managedSettings: accountPolicyData?.policyData.managedSettings }, - fetchedAt: accountPolicyData?.managedSettingsFetchedAt, - scope: accountPolicyData?.managedSettingsScope ?? scope, + data: { managedSettings: scopedManagedSettings }, + fetchedAt: scopedManagedSettingsFetchedAt, + scope, compatibilityError: this._managedSettingsCompatibilityError, }; } - const fetchScopeKey = this.getManagedSettingsScopeKey(scope); - const hasFetchedThisProcess = this.managedSettingsFetchAttemptedAccounts.has(fetchScopeKey); const freshnessSatisfied = requirement.effective && isManagedSettingsFreshnessSatisfiedFor(this._managedSettingsFreshness, scope); - if (!options?.forceRefresh && cachedManagedSettings && ((hasFetchedThisProcess && !requirement.effective) || freshnessSatisfied)) { + // When forceRemoteSettingsRefresh is effective, reuse also requires this scope's freshness to be + // satisfied; an outstanding compatibility error always forces revalidation. + if (!options?.forceRefresh && scopedCachedManagedSettings && (!requirement.effective || freshnessSatisfied) && !this._managedSettingsCompatibilityError) { this.logService.debug('[DefaultAccount] Using last fetched managed settings data'); - return { ...cachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; + return { ...scopedCachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; } const lastAttemptAt = Date.now(); @@ -1160,7 +1183,6 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun lastAttemptAt, }); } - this.managedSettingsFetchAttemptedAccounts.add(fetchScopeKey); const sharedBackoffActive = Date.now() < this._rateLimitBackoffUntil; const result = await this.requestManagedSettings(requirement.effective ? [sessions[0]] : sessions, managedSettingsUrl); if (requirement.effective && !sharedBackoffActive) { @@ -1188,8 +1210,8 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun }); } return { - data: requirement.effective ? { managedSettings: accountPolicyData?.policyData.managedSettings } : { managedSettings: undefined }, - fetchedAt: requirement.effective ? accountPolicyData?.managedSettingsFetchedAt : Date.now(), + data: requirement.effective ? { managedSettings: scopedManagedSettings } : { managedSettings: undefined }, + fetchedAt: requirement.effective ? scopedManagedSettingsFetchedAt : Date.now(), scope, compatibilityError: result.error, }; @@ -1200,14 +1222,14 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun if (requirement.effective) { this.setManagedSettingsFreshness(this.toBlockedManagedSettingsFreshness(requirement.source, result, lastAttemptAt, scope)); return { - data: { managedSettings: accountPolicyData?.policyData.managedSettings }, - fetchedAt: accountPolicyData?.managedSettingsFetchedAt, + data: { managedSettings: scopedManagedSettings }, + fetchedAt: scopedManagedSettingsFetchedAt, scope, compatibilityError: this._managedSettingsCompatibilityError, }; } // A failed fetch must not extend the life of the cached response: carry the cache's timestamp for expiry - const retained = this._managedSettingsCompatibilityError ? undefined : cachedManagedSettings; + const retained = this._managedSettingsCompatibilityError ? undefined : scopedCachedManagedSettings; return { data: { managedSettings: retained?.data.managedSettings }, fetchedAt: retained?.fetchedAt, diff --git a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts index acbd39eb3fbe4d..e1b3af95aadd65 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -23,14 +24,14 @@ import { IRequestService } from '../../../../../platform/request/common/request. import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { AuthenticationSession, IAuthenticationExtensionsService, IAuthenticationService } from '../../../authentication/common/authentication.js'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationExtensionsService, IAuthenticationService } from '../../../authentication/common/authentication.js'; import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; import { IExtensionService } from '../../../extensions/common/extensions.js'; import { IHostService } from '../../../host/browser/host.js'; import { DefaultAccountProvider } from '../../browser/defaultAccount.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; -suite('DefaultAccountProvider managed settings', () => { +suite('DefaultAccountProvider', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const accountId = 'account'; @@ -67,7 +68,7 @@ suite('DefaultAccountProvider managed settings', () => { }); }); - test('settings without a refresh requirement use the cache after one process fetch', async () => { + test('settings without a refresh requirement use the cache without fetching', async () => { const requestService = new TestRequestService(async () => jsonResponse({ permissions: { disableBypassPermissionsMode: 'disable' }, })); @@ -82,12 +83,35 @@ suite('DefaultAccountProvider managed settings', () => { first: first.data, second: second.data, }, { - requestCount: 1, - first: { managedSettings: { 'permissions.disableBypassPermissionsMode': 'disable' } }, + requestCount: 0, + first: cachedPolicy.policyData, second: cachedPolicy.policyData, }); }); + test('forceRefresh fetches fresh even when the cache is fresh, without it the cache is honored', async () => { + const requestService = new TestRequestService(async () => jsonResponse({ + permissions: { disableBypassPermissionsMode: 'disable' }, + })); + const provider = await createProvider(requestService); + const cachedPolicy = createCachedPolicy(false); + + // Without forceRefresh the fresh cache is served with no network round-trip. + const cached = await provider['getManagedSettings'](sessions, cachedPolicy); + // The forceRefresh command bypasses the fresh cache and fetches. + const forced = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true, retryManagedSettings: true }); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + cached: cached.data, + forced: forced.data, + }, { + requestCount: 1, + cached: cachedPolicy.policyData, + forced: { managedSettings: { 'permissions.disableBypassPermissionsMode': 'disable' } }, + }); + }); + test('settings without a refresh requirement refetch only after the cache becomes stale', async () => { const requestService = new TestRequestService(async () => jsonResponse({})); const provider = await createProvider(requestService); @@ -100,7 +124,136 @@ suite('DefaultAccountProvider managed settings', () => { managedSettingsFetchedAt: Date.now() - 60 * 60 * 1000, }); - assert.strictEqual(requestService.requestCount, 2); + assert.strictEqual(requestService.requestCount, 1); + }); + + test('outstanding compatibility error revalidates instead of serving a fresh cache', async () => { + const requestService = new TestRequestService(async () => jsonResponse({})); + const provider = await createProvider(requestService); + provider['setManagedSettingsCompatibilityError']({ errorCode: 'client_update_required' }); + const cachedPolicy = createCachedPolicy(false); + + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + compatibilityError: result.compatibilityError, + }, { + requestCount: 1, + compatibilityError: null, + }); + }); + + test('a fresh cache from a different scope is not reused', async () => { + const requestService = new TestRequestService(async () => jsonResponse({ + permissions: { disableBypassPermissionsMode: 'disable' }, + })); + const provider = await createProvider(requestService); + const cachedPolicy = { + ...createCachedPolicy(false), + managedSettingsScope: { + accountId, + authenticationProviderId: 'github-enterprise', + endpointOrigin: 'https://api.ghe.example.com', + }, + }; + + // The cache was captured for a different provider/endpoint than the current github/api.github.com + // scope, so it must be revalidated rather than served for the rest of the cache lifetime. + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + data: result.data, + }, { + requestCount: 1, + data: { managedSettings: { 'permissions.disableBypassPermissionsMode': 'disable' } }, + }); + }); + + test('a fresh cache from a different scope is not retained when refetch fails', async () => { + const requestService = new TestRequestService(async () => { + throw new Error('managed settings unavailable'); + }); + const provider = await createProvider(requestService); + const cachedPolicy = { + ...createCachedPolicy(false), + managedSettingsScope: { + accountId, + authenticationProviderId: 'github-enterprise', + endpointOrigin: 'https://api.ghe.example.com', + }, + }; + + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + data: result.data, + fetchedAt: result.fetchedAt, + scope: result.scope, + }, { + requestCount: 1, + data: { managedSettings: undefined }, + fetchedAt: undefined, + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + }); + }); + + test('a cache from a different scope is not retained by forced refresh failure or retry blocking', async () => { + const requestService = new TestRequestService(async () => { + throw new Error('managed settings unavailable'); + }); + const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + const cachedPolicy = { + ...createCachedPolicy(false), + managedSettingsScope: { + accountId, + authenticationProviderId: 'github-enterprise', + endpointOrigin: 'https://api.ghe.example.com', + }, + }; + + const failed = await provider['getManagedSettings'](sessions, cachedPolicy); + const blocked = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + failed: { + data: failed.data, + fetchedAt: failed.fetchedAt, + scope: failed.scope, + }, + blocked: { + data: blocked.data, + fetchedAt: blocked.fetchedAt, + scope: blocked.scope, + }, + }, { + requestCount: 1, + failed: { + data: { managedSettings: undefined }, + fetchedAt: undefined, + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + }, + blocked: { + data: { managedSettings: undefined }, + fetchedAt: undefined, + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + }, + }); }); test('fresh 404 clears a cached server requirement', async () => { @@ -154,7 +307,7 @@ suite('DefaultAccountProvider managed settings', () => { const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - const result = await provider['getManagedSettings'](sessions, cachedPolicy); + const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.deepStrictEqual({ status: provider.managedSettingsFetchStatus, @@ -175,7 +328,7 @@ suite('DefaultAccountProvider managed settings', () => { const requestService = new TestRequestService(async () => jsonResponse({ error_code: 'unexpected' }, 466)); const provider = await createProvider(requestService); - const result = await provider['getManagedSettings'](sessions, createCachedPolicy(false)); + const result = await provider['getManagedSettings'](sessions, createCachedPolicy(false), { forceRefresh: true }); assert.deepStrictEqual({ data: result.data, @@ -211,14 +364,14 @@ suite('DefaultAccountProvider managed settings', () => { }); }); - test('failed startup fetch retains cached managed settings when no rejection is known', async () => { + test('failed forced refresh retains cached managed settings when no rejection is known', async () => { const requestService = new TestRequestService(async () => { throw new Error('managed settings unavailable'); }); const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - const result = await provider['getManagedSettings'](sessions, cachedPolicy); + const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.deepStrictEqual({ requestCount: requestService.requestCount, @@ -324,7 +477,7 @@ suite('DefaultAccountProvider managed settings', () => { const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - await provider['getManagedSettings'](sessions, cachedPolicy); + await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.strictEqual(requestService.requestCount, 2); @@ -731,7 +884,7 @@ suite('DefaultAccountProvider managed settings', () => { const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - await provider['getManagedSettings'](sessions, cachedPolicy); + await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.deepStrictEqual({ @@ -775,11 +928,122 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('reconciles a replacement without a signed-out gap and preserves removal-only behavior', async () => { + const sessionChanges = disposables.add(new Emitter<{ providerId: string; label: string; event: AuthenticationSessionsChangeEvent }>()); + let authenticationSessions = sessions; + const provider = await createProvider( + new TestRequestService(async () => jsonResponse({ chat_enabled: true })), + {}, + {}, + '', + { + getSessions: async () => authenticationSessions, + onDidChangeSessions: sessionChanges.event, + } + ); + const observedSessionIds: Array = []; + disposables.add(provider.onDidChangeDefaultAccount(account => observedSessionIds.push(account?.sessionId ?? null))); + const replacementSession = { ...sessions[0], id: 'replacement-session', accessToken: 'replacement-token' }; + authenticationSessions = [replacementSession]; + const beforeReplacement = provider.defaultAccount?.sessionId; + const replacement = Event.toPromise(Event.filter( + provider.onDidChangeDefaultAccount, + account => account?.sessionId === replacementSession.id + )); + + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [replacementSession], removed: sessions, changed: [] }, + }); + const afterReplacementEvent = provider.defaultAccount?.sessionId; + const afterReplacement = (await replacement)?.sessionId; + + authenticationSessions = []; + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [], removed: [replacementSession], changed: [] }, + }); + + assert.deepStrictEqual({ + beforeReplacement, + afterReplacementEvent, + afterReplacement, + afterRemovalOnlyEvent: provider.defaultAccount?.sessionId, + observedSessionIds, + }, { + beforeReplacement: 'session', + afterReplacementEvent: 'session', + afterReplacement: 'replacement-session', + afterRemovalOnlyEvent: undefined, + observedSessionIds: ['replacement-session', null], + }); + }); + + test('does not restore a removed session from an in-flight replacement refresh', async () => { + const sessionChanges = disposables.add(new Emitter<{ providerId: string; label: string; event: AuthenticationSessionsChangeEvent }>()); + const refreshStarted = new DeferredPromise(); + const releaseRefresh = new DeferredPromise(); + let authenticationSessions = sessions; + let blockRefresh = false; + const provider = await createProvider( + new TestRequestService(async options => { + if (blockRefresh && options.callSite === 'defaultAccount.entitlements') { + refreshStarted.complete(); + return releaseRefresh.p; + } + return jsonResponse({ chat_enabled: true }); + }), + {}, + {}, + '', + { + getSessions: async () => authenticationSessions, + onDidChangeSessions: sessionChanges.event, + } + ); + const observedSessionIds: Array = []; + disposables.add(provider.onDidChangeDefaultAccount(account => observedSessionIds.push(account?.sessionId ?? null))); + const replacementSession = { ...sessions[0], accessToken: 'replacement-token' }; + authenticationSessions = [replacementSession]; + blockRefresh = true; + + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [replacementSession], removed: sessions, changed: [] }, + }); + const replacementRefresh = provider.refresh({ forceRefresh: true }); + await refreshStarted.p; + + authenticationSessions = []; + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [], removed: [replacementSession], changed: [] }, + }); + const afterRemoval = provider.defaultAccount?.sessionId; + releaseRefresh.complete(jsonResponse({ chat_enabled: false })); + await replacementRefresh; + + assert.deepStrictEqual({ + afterRemoval, + afterBlockedRefresh: provider.defaultAccount?.sessionId, + observedSessionIds, + }, { + afterRemoval: undefined, + afterBlockedRefresh: undefined, + observedSessionIds: [null], + }); + }); + async function createProvider( requestService: TestRequestService, nativeManagedSettings: ManagedSettingsData = {}, fileManagedSettings: ManagedSettingsData = {}, - managedSettingsUrl = 'https://api.github.com/copilot_internal/managed_settings' + managedSettingsUrl = 'https://api.github.com/copilot_internal/managed_settings', + authenticationServiceOverrides: Partial = {}, ): Promise { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService()); @@ -792,6 +1056,7 @@ suite('DefaultAccountProvider managed settings', () => { onDidChangeSessions: Event.None, onDidRegisterAuthenticationProvider: Event.None, onDidUnregisterAuthenticationProvider: Event.None, + ...authenticationServiceOverrides, }); instantiationService.stub(IAuthenticationExtensionsService, { getAccountPreference: () => undefined, @@ -802,7 +1067,7 @@ suite('DefaultAccountProvider managed settings', () => { instantiationService.stub(IRequestService, requestService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IWorkbenchEnvironmentService, { - remoteAuthority: undefined, + remoteAuthority: isWeb ? 'test-remote' : undefined, isSessionsWindow: false, }); instantiationService.stub(IProductService, { diff --git a/src/vs/workbench/services/configuration/browser/configurationService.ts b/src/vs/workbench/services/configuration/browser/configurationService.ts index 963ac7e283cba4..44728b84e4e937 100644 --- a/src/vs/workbench/services/configuration/browser/configurationService.ts +++ b/src/vs/workbench/services/configuration/browser/configurationService.ts @@ -1352,6 +1352,7 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem private readonly processedExperimentalSettings = new Set(); private readonly autoExperimentalSettings = new Set(); + private readonly pendingStartupExperimentalSettings = new Set(); private readonly registeredExperimentalDefaults = new Map(); private readonly configurationRegistry = Registry.as(Extensions.Configuration); private readonly throttler = this._register(new Throttler()); @@ -1366,7 +1367,8 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem super(); this.throttler.queue(() => this.updateDefaults()); - this._register(workbenchAssignmentService.onDidRefetchAssignments(() => this.throttler.queue(() => this.processExperimentalSettings(this.autoExperimentalSettings, true)))); + // Re-resolve `auto` settings and any still-pending `startup` settings on each refetch. + this._register(workbenchAssignmentService.onDidRefetchAssignments(() => this.throttler.queue(() => this.processExperimentalSettings([...this.autoExperimentalSettings, ...this.pendingStartupExperimentalSettings], true)))); // When configuration is updated make sure to apply experimental configuration overrides this._register(this.configurationRegistry.onDidUpdateConfiguration(({ properties }) => this.processExperimentalSettings(properties, false))); @@ -1402,6 +1404,7 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem } this.processedExperimentalSettings.delete(property); this.autoExperimentalSettings.delete(property); + this.pendingStartupExperimentalSettings.delete(property); continue; } const defaultValueSource: ConfigurationDefaultSource | undefined = schema.defaultValueSource && !(schema.defaultValueSource instanceof Map) ? schema.defaultValueSource : undefined; @@ -1412,17 +1415,28 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem removedDefaults.push(registeredDefault); } this.processedExperimentalSettings.delete(property); + this.pendingStartupExperimentalSettings.delete(property); continue; } if (!autoRefetch && this.processedExperimentalSettings.has(property)) { continue; } this.processedExperimentalSettings.add(property); - if (schema.experiment.mode === 'auto') { + const isAutoExperiment = schema.experiment.mode === 'auto'; + if (isAutoExperiment) { this.autoExperimentalSettings.add(property); } try { const value = await this.workbenchAssignmentService.getTreatment(schema.experiment.name ?? `config.${property}`); + // Latch a `startup` value once it first resolves; keep it pending until then so a + // later (sign-in gated) value can still be applied. + if (!isAutoExperiment) { + if (isUndefined(value)) { + this.pendingStartupExperimentalSettings.add(property); + } else { + this.pendingStartupExperimentalSettings.delete(property); + } + } const registeredDefault = this.registeredExperimentalDefaults.get(property); if (this.shouldOverride(value, schema)) { if (!equals(registeredDefault?.overrides[property], value)) { diff --git a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts index 3baaf29d9b2055..6cc104950ed4cc 100644 --- a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts @@ -35,7 +35,9 @@ import { IKeybindingEditingService, KeybindingsEditingService } from '../../../k import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; import { timeout } from '../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IWorkbenchAssignmentService } from '../../../assignment/common/assignmentService.js'; +import { IExtensionService } from '../../../extensions/common/extensions.js'; import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { BrowserWorkbenchEnvironmentService, IBrowserWorkbenchEnvironmentService } from '../../../environment/browser/environmentService.js'; @@ -92,6 +94,7 @@ suite('ConfigurationDefaultOverridesContribution', () => { type TestContribution = { processedExperimentalSettings: Set; autoExperimentalSettings: Set; + pendingStartupExperimentalSettings: Set; registeredExperimentalDefaults: Map; configurationRegistry: IConfigurationRegistry; workbenchAssignmentService: { @@ -103,6 +106,7 @@ suite('ConfigurationDefaultOverridesContribution', () => { const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; contribution.processedExperimentalSettings = new Set(); contribution.autoExperimentalSettings = new Set(); + contribution.pendingStartupExperimentalSettings = new Set(); contribution.registeredExperimentalDefaults = new Map(); contribution.configurationRegistry = configurationRegistry; contribution.workbenchAssignmentService = { @@ -156,6 +160,223 @@ suite('ConfigurationDefaultOverridesContribution', () => { configurationRegistry.deregisterConfigurations([configuration]); } }); + + test('defers a startup experiment until its value first resolves, then latches it', async () => { + const startupSetting = 'test.startupExperimentalSetting'; + const startupConfiguration: IConfigurationNode = { + id: 'test.startupExperimentalSettings', + type: 'object', + properties: { + [startupSetting]: { + type: 'string', + default: 'control', + experiment: { + mode: 'startup', + name: 'testStartupExperimentalSetting' + } + } + } + }; + // The treatment is unavailable during the initial resolution (as with a sign-in gated + // assignments endpoint) and only becomes available on a later refetch. + const treatments: Record = { + testStartupExperimentalSetting: undefined, + }; + type TestContribution = { + processedExperimentalSettings: Set; + autoExperimentalSettings: Set; + pendingStartupExperimentalSettings: Set; + registeredExperimentalDefaults: Map; + configurationRegistry: IConfigurationRegistry; + workbenchAssignmentService: { + getTreatment(name: string): Promise; + }; + environmentService: { isSessionsWindow: boolean }; + processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; + }; + const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; + contribution.processedExperimentalSettings = new Set(); + contribution.autoExperimentalSettings = new Set(); + contribution.pendingStartupExperimentalSettings = new Set(); + contribution.registeredExperimentalDefaults = new Map(); + contribution.configurationRegistry = configurationRegistry; + contribution.workbenchAssignmentService = { + getTreatment: async (name: string) => treatments[name] as T | undefined, + }; + contribution.environmentService = { isSessionsWindow: false }; + configurationRegistry.registerConfiguration(startupConfiguration); + + const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; + // Mirror the refetch handler, which re-resolves only pending startup settings. + const refetch = () => contribution.processExperimentalSettings([...contribution.pendingStartupExperimentalSettings], true); + + try { + // Initial resolution: no value yet, so the default is untouched and the setting is pending. + await contribution.processExperimentalSettings([startupSetting], false); + const afterInitial = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + // The value becomes available; the next refetch resolves and latches it. + treatments.testStartupExperimentalSetting = 'treatment'; + await refetch(); + const afterResolved = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + // A later assignment change must not move a latched startup value. + treatments.testStartupExperimentalSetting = 'changed'; + await refetch(); + const afterLatched = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + assert.deepStrictEqual({ afterInitial, afterResolved, afterLatched }, { + afterInitial: { default: 'control', pending: true }, + afterResolved: { default: 'treatment', pending: false }, + afterLatched: { default: 'treatment', pending: false }, + }); + } finally { + if (contribution.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...contribution.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterConfigurations([startupConfiguration]); + } + }); + + test('does not re-resolve a startup experiment that already resolved at startup', async () => { + const startupSetting = 'test.resolvedStartupExperimentalSetting'; + const startupConfiguration: IConfigurationNode = { + id: 'test.resolvedStartupExperimentalSettings', + type: 'object', + properties: { + [startupSetting]: { + type: 'string', + default: 'control', + experiment: { + mode: 'startup', + name: 'testResolvedStartupExperimentalSetting' + } + } + } + }; + // The value is available during the initial resolution (as with the legacy endpoint, which + // resolves before sign-in). + const treatments: Record = { + testResolvedStartupExperimentalSetting: 'treatment', + }; + type TestContribution = { + processedExperimentalSettings: Set; + autoExperimentalSettings: Set; + pendingStartupExperimentalSettings: Set; + registeredExperimentalDefaults: Map; + configurationRegistry: IConfigurationRegistry; + workbenchAssignmentService: { + getTreatment(name: string): Promise; + }; + environmentService: { isSessionsWindow: boolean }; + processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; + }; + const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; + contribution.processedExperimentalSettings = new Set(); + contribution.autoExperimentalSettings = new Set(); + contribution.pendingStartupExperimentalSettings = new Set(); + contribution.registeredExperimentalDefaults = new Map(); + contribution.configurationRegistry = configurationRegistry; + contribution.workbenchAssignmentService = { + getTreatment: async (name: string) => treatments[name] as T | undefined, + }; + contribution.environmentService = { isSessionsWindow: false }; + configurationRegistry.registerConfiguration(startupConfiguration); + + const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; + // Mirror the refetch handler, which re-resolves only pending startup settings. + const refetch = () => contribution.processExperimentalSettings([...contribution.pendingStartupExperimentalSettings], true); + + try { + // Initial resolution captures the value and latches it (it is never pending). + await contribution.processExperimentalSettings([startupSetting], false); + const afterInitial = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + // A later assignment change (e.g. after sign-in) must not move an already-resolved value. + treatments.testResolvedStartupExperimentalSetting = 'changed'; + await refetch(); + const afterRefetch = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + assert.deepStrictEqual({ afterInitial, afterRefetch }, { + afterInitial: { default: 'treatment', pending: false }, + afterRefetch: { default: 'treatment', pending: false }, + }); + } finally { + if (contribution.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...contribution.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterConfigurations([startupConfiguration]); + } + }); + + test('re-resolves pending startup settings when onDidRefetchAssignments fires', async () => { + const startupSetting = 'test.wiredStartupExperimentalSetting'; + const startupConfiguration: IConfigurationNode = { + id: 'test.wiredStartupExperimentalSettings', + type: 'object', + properties: { + [startupSetting]: { + type: 'string', + default: 'control', + experiment: { + mode: 'startup', + name: 'testWiredStartupExperimentalSetting' + } + } + } + }; + // No value at startup, so the setting stays pending until a value arrives on a refetch. + const treatments: Record = { + testWiredStartupExperimentalSetting: undefined, + }; + const onDidRefetchAssignments = new Emitter(); + + // Construct the real contribution so the constructor's `onDidRefetchAssignments` -> refetch + // wiring is exercised end to end (rather than calling processExperimentalSettings directly). + const workbenchAssignmentService = { + onDidRefetchAssignments: onDidRefetchAssignments.event, + getTreatment: async (name: string) => treatments[name], + } as unknown as IWorkbenchAssignmentService; + const extensionService = { whenInstalledExtensionsRegistered: async () => true } as unknown as IExtensionService; + const workspaceService = { reloadConfiguration: async () => { } } as unknown as WorkspaceService; + const environmentService = { isSessionsWindow: false } as unknown as IWorkbenchEnvironmentService; + + configurationRegistry.registerConfiguration(startupConfiguration); + const contribution = new ConfigurationDefaultOverridesContribution(workbenchAssignmentService, extensionService, workspaceService, environmentService, new NullLogService()); + const internals = contribution as unknown as { + pendingStartupExperimentalSettings: Set; + registeredExperimentalDefaults: Map; + }; + const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; + const waitFor = async (predicate: () => boolean) => { + for (let i = 0; i < 100 && !predicate(); i++) { + await timeout(0); + } + }; + + try { + // The constructor's initial resolution finds no value, so the setting becomes pending. + await waitFor(() => internals.pendingStartupExperimentalSettings.has(startupSetting)); + const afterInitial = { default: readDefault(), pending: internals.pendingStartupExperimentalSettings.has(startupSetting) }; + + // A value arrives; firing the event must re-resolve the pending setting through the wiring. + treatments.testWiredStartupExperimentalSetting = 'treatment'; + onDidRefetchAssignments.fire(); + await waitFor(() => readDefault() === 'treatment'); + + assert.deepStrictEqual({ afterInitial, afterRefetch: { default: readDefault(), pending: internals.pendingStartupExperimentalSettings.has(startupSetting) } }, { + afterInitial: { default: 'control', pending: true }, + afterRefetch: { default: 'treatment', pending: false }, + }); + } finally { + contribution.dispose(); + onDidRefetchAssignments.dispose(); + if (internals.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...internals.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterConfigurations([startupConfiguration]); + } + }); }); function convertToWorkspacePayload(folder: URI): ISingleFolderWorkspaceIdentifier { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts index 53e11fc2848b83..28fa7ebc98c9a6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts @@ -11,6 +11,7 @@ import { buildAgentMergePrompt, IAgentMergePromptSummary, parseAgentMergePrompt import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; import { ChatAgentMergeContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.js'; @@ -178,6 +179,9 @@ function renderAgentMerge({ container, disposableStore, theme }: ComponentFixtur const instantiationService = createEditorServices(disposableStore, { colorTheme: theme, additionalServices: (reg) => { + reg.defineInstance(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { return uri.path; } + }()); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(ICommandService, commandService); }, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts index 714c5161665e08..ada77185c037f8 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice } from '../../../../../platform/agentHost/common/agentMerge.js'; import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { systemNotificationToChatPart } from '../../../../contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.js'; import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; @@ -35,6 +37,9 @@ function renderNotice(context: ComponentFixtureContext, content: string, kind: A const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { + reg.defineInstance(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { return uri.path; } + }()); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(IChatMarkdownAnchorService, anchorService); }, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts index e954fd2b23a9aa..8536d831dd8307 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts @@ -9,7 +9,9 @@ import { Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatProgressContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.js'; import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; @@ -68,6 +70,9 @@ function renderProgressPart( const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { + reg.defineInstance(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { return uri.path; } + }()); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(IChatMarkdownAnchorService, mockAnchorService); }, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts index 1503bb3ad399ba..40981ce1c85e9b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts @@ -86,6 +86,8 @@ export interface ChatInputFixtureOptions { readonly selection?: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; /** Sets the fixture width, useful for exercising the compact picker layout. */ readonly width?: number; + /** Applies additional widths after initial layout to exercise responsive restoration. */ + readonly resizeWidths?: readonly number[]; /** Supplies models so the picker renders provider icons. */ readonly models?: readonly ILanguageModelChatMetadataAndIdentifier[]; /** Renders a standalone dictation / Voice Mode control in the given state. */ @@ -102,7 +104,7 @@ export interface ChatInputFixtureOptions { export async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: ChatInputFixtureOptions = {}): Promise { const { container, disposableStore } = context; - const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, models = [], voiceControl, notification, pet = false } = fixtureOptions; + const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, resizeWidths = [], models = [], voiceControl, notification, pet = false } = fixtureOptions; const artifactGroups: IArtifactSourceGroup[] = artifacts.length > 0 ? [{ source: { kind: 'agent' as const }, artifacts }] : []; const artifactsObs = observableValue('artifactGroups', artifactGroups); @@ -233,6 +235,13 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO inputPart.layout(width); await new Promise(r => setTimeout(r, 100)); inputPart.layout(width); + if (resizeWidths.length > 0) { + await Promise.all(resizeWidths.map((resizeWidth, index) => new Promise(resolve => setTimeout(() => { + container.style.width = `${resizeWidth}px`; + inputPart.layout(resizeWidth); + resolve(); + }, index * 16)))); + } if (value !== undefined) { inputPart.setValue(value, true); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index d8ebdb63585bd2..8d502bb1c4ed46 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -163,10 +163,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f70224f7733a2461eba63fa98234aab38b8804a73460deffa11f49cd6f7172c) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7ae2b4a47fd55deedfef3304fd218f5768fa4847a6ce8749a005755e6bec2d3f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1a8351614d9e211d7f63db3f1f56a46934271cfdbda83b0b72298968d0f5eec5) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/48d5afd53482fa7155777eaf6a6a2dc1f809f5b945d08a9befe9eee63eaf8b0b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a6412a92ec5265fe65d714be9a9af4e0feb95fed7378ca1477629fb2391485a0) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0752cf02ae3a4e21fce84b62859df32a5f41c13622bdec0083a3fd46832c2e0a)