From 5e957b692222111dfd421b3bcd4aa60c2f7d2967 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:49:29 -0700 Subject: [PATCH 01/15] Update build ts version --- build/package-lock.json | 8 +++ build/package.json | 1 + build/tsconfig.json | 3 + .../client/tsconfig.json | 4 ++ .../server/tsconfig.json | 3 + extensions/git-base/tsconfig.json | 3 + .../github-authentication/tsconfig.json | 7 +- .../client/tsconfig.json | 4 ++ .../server/tsconfig.json | 3 + extensions/ipynb/tsconfig.json | 3 + .../client/tsconfig.json | 4 ++ .../server/tsconfig.json | 3 + extensions/media-preview/tsconfig.json | 3 + .../microsoft-authentication/tsconfig.json | 10 ++- extensions/references-view/tsconfig.json | 3 + extensions/search-result/tsconfig.json | 3 + extensions/simple-browser/tsconfig.json | 3 + package-lock.json | 72 +++++++++---------- package.json | 6 +- 19 files changed, 105 insertions(+), 41 deletions(-) diff --git a/build/package-lock.json b/build/package-lock.json index 92f3b6a4a3e70..08ee301f06960 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -38,6 +38,7 @@ "@types/mime": "0.0.29", "@types/minimatch": "^3.0.3", "@types/minimist": "^1.2.1", + "@types/mocha": "^10.0.10", "@types/node": "^22.18.10", "@types/p-all": "^1.0.0", "@types/pump": "^1.0.1", @@ -1771,6 +1772,13 @@ "integrity": "sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==", "dev": true }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "0.7.32", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.32.tgz", diff --git a/build/package.json b/build/package.json index 8a65120c4d60e..a7b47427ff6d3 100644 --- a/build/package.json +++ b/build/package.json @@ -32,6 +32,7 @@ "@types/mime": "0.0.29", "@types/minimatch": "^3.0.3", "@types/minimist": "^1.2.1", + "@types/mocha": "^10.0.10", "@types/node": "^22.18.10", "@types/p-all": "^1.0.0", "@types/pump": "^1.0.1", diff --git a/build/tsconfig.json b/build/tsconfig.json index 383d5342c044a..667fefe75be3e 100644 --- a/build/tsconfig.json +++ b/build/tsconfig.json @@ -4,6 +4,9 @@ "lib": [ "ES2024" ], + "types": [ + "mocha" + ], "module": "nodenext", "noEmit": true, "erasableSyntaxOnly": true, diff --git a/extensions/css-language-features/client/tsconfig.json b/extensions/css-language-features/client/tsconfig.json index af9ff253d7930..a9e12e11016ce 100644 --- a/extensions/css-language-features/client/tsconfig.json +++ b/extensions/css-language-features/client/tsconfig.json @@ -4,9 +4,13 @@ "rootDir": "./src", "outDir": "./out", "lib": [ + "ES2024", "webworker" ], "module": "Node16", + "types": [ + "node" + ], "typeRoots": [ "../node_modules/@types" ] diff --git a/extensions/css-language-features/server/tsconfig.json b/extensions/css-language-features/server/tsconfig.json index 97428f411f9b5..0bfa742933957 100644 --- a/extensions/css-language-features/server/tsconfig.json +++ b/extensions/css-language-features/server/tsconfig.json @@ -8,6 +8,9 @@ "WebWorker" ], "module": "Node16", + "types": [ + "node" + ], "typeRoots": [ "../node_modules/@types" ] diff --git a/extensions/git-base/tsconfig.json b/extensions/git-base/tsconfig.json index e723410bedf60..a2cbe0e9ea352 100644 --- a/extensions/git-base/tsconfig.json +++ b/extensions/git-base/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./out", + "types": [ + "node" + ], "typeRoots": [ "./node_modules/@types" ] diff --git a/extensions/github-authentication/tsconfig.json b/extensions/github-authentication/tsconfig.json index eac554232a0a5..d0e92883970b0 100644 --- a/extensions/github-authentication/tsconfig.json +++ b/extensions/github-authentication/tsconfig.json @@ -3,11 +3,16 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./out", + "types": [ + "node", + "mocha" + ], "typeRoots": [ "./node_modules/@types" ], "lib": [ - "WebWorker" + "WebWorker", + "ES2024" ] }, "include": [ diff --git a/extensions/html-language-features/client/tsconfig.json b/extensions/html-language-features/client/tsconfig.json index b919fbffad520..3a191e68ebd0b 100644 --- a/extensions/html-language-features/client/tsconfig.json +++ b/extensions/html-language-features/client/tsconfig.json @@ -4,9 +4,13 @@ "rootDir": "./src", "outDir": "./out", "lib": [ + "ES2024", "webworker" ], "module": "Node16", + "types": [ + "node" + ], "typeRoots": [ "../node_modules/@types" ] diff --git a/extensions/html-language-features/server/tsconfig.json b/extensions/html-language-features/server/tsconfig.json index 97428f411f9b5..0bfa742933957 100644 --- a/extensions/html-language-features/server/tsconfig.json +++ b/extensions/html-language-features/server/tsconfig.json @@ -8,6 +8,9 @@ "WebWorker" ], "module": "Node16", + "types": [ + "node" + ], "typeRoots": [ "../node_modules/@types" ] diff --git a/extensions/ipynb/tsconfig.json b/extensions/ipynb/tsconfig.json index e95df8b001511..f6edb335ef6e0 100644 --- a/extensions/ipynb/tsconfig.json +++ b/extensions/ipynb/tsconfig.json @@ -7,6 +7,9 @@ "ES2024", "DOM" ], + "types": [ + "node" + ], "typeRoots": [ "./node_modules/@types" ] diff --git a/extensions/json-language-features/client/tsconfig.json b/extensions/json-language-features/client/tsconfig.json index 10c85fba3b49b..601d6b62078e2 100644 --- a/extensions/json-language-features/client/tsconfig.json +++ b/extensions/json-language-features/client/tsconfig.json @@ -4,9 +4,13 @@ "rootDir": "./src", "outDir": "./out", "lib": [ + "ES2024", "webworker" ], "module": "Node16", + "types": [ + "node" + ], "typeRoots": [ "../node_modules/@types" ] diff --git a/extensions/json-language-features/server/tsconfig.json b/extensions/json-language-features/server/tsconfig.json index 2c01a5ed33262..e5e86165fb0ca 100644 --- a/extensions/json-language-features/server/tsconfig.json +++ b/extensions/json-language-features/server/tsconfig.json @@ -10,6 +10,9 @@ "WebWorker" ], "module": "Node16", + "types": [ + "node" + ], "typeRoots": [ "../node_modules/@types" ] diff --git a/extensions/media-preview/tsconfig.json b/extensions/media-preview/tsconfig.json index e723410bedf60..a2cbe0e9ea352 100644 --- a/extensions/media-preview/tsconfig.json +++ b/extensions/media-preview/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./out", + "types": [ + "node" + ], "typeRoots": [ "./node_modules/@types" ] diff --git a/extensions/microsoft-authentication/tsconfig.json b/extensions/microsoft-authentication/tsconfig.json index 942183279d030..809cfd069d63b 100644 --- a/extensions/microsoft-authentication/tsconfig.json +++ b/extensions/microsoft-authentication/tsconfig.json @@ -5,7 +5,15 @@ "outDir": "./out", "noFallthroughCasesInSwitch": true, "noUnusedLocals": false, - "skipLibCheck": true + "skipLibCheck": true, + "types": [ + "node", + "mocha" + ], + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types" + ] }, "include": [ "src/**/*", diff --git a/extensions/references-view/tsconfig.json b/extensions/references-view/tsconfig.json index e723410bedf60..a2cbe0e9ea352 100644 --- a/extensions/references-view/tsconfig.json +++ b/extensions/references-view/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./out", + "types": [ + "node" + ], "typeRoots": [ "./node_modules/@types" ] diff --git a/extensions/search-result/tsconfig.json b/extensions/search-result/tsconfig.json index e723410bedf60..a2cbe0e9ea352 100644 --- a/extensions/search-result/tsconfig.json +++ b/extensions/search-result/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./out", + "types": [ + "node" + ], "typeRoots": [ "./node_modules/@types" ] diff --git a/extensions/simple-browser/tsconfig.json b/extensions/simple-browser/tsconfig.json index 8f53af26c0985..8d17f161dbdde 100644 --- a/extensions/simple-browser/tsconfig.json +++ b/extensions/simple-browser/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./out", + "types": [ + "node" + ], "typeRoots": [ "./node_modules/@types" ] diff --git a/package-lock.json b/package-lock.json index 7126134230aec..2ad7243503ff5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", - "@typescript/native-preview": "^7.0.0-dev.20260306", + "@typescript/native-preview": "^7.0.0-dev.20260408", "@vscode/component-explorer": "^0.2.1-8", "@vscode/component-explorer-cli": "^0.2.1-7", "@vscode/gulp-electron": "1.41.0", @@ -163,7 +163,7 @@ "tar": "^7.5.9", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^6.0.0-dev.20260306", + "typescript": "^6.0.0-dev.20260401", "typescript-eslint": "^8.45.0", "util": "^0.12.4", "xml2js": "^0.5.0", @@ -3284,28 +3284,28 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-4m7cOjtKu+iLazWW5MuJuI2ZZMkQkS42+GxN6FVdja1nL0t47l1wpaTnzUa1Ny9Xa0opIJ7psPAMBKYAPKbCKA==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-N0MZLEUnAoP/aRVk7MY119LDsESkbtEwIw+YeXi/jjx2XCqf7ni3GxIVsUYtf/troyuSedq3V/OUrkoCh5A9gA==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260306.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260306.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260306.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260306.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260306.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260306.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260306.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260408.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260408.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260408.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260408.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260408.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260408.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260408.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-4vuh4VlPydMS/nymDzjJIKDk3dntnEEB5UzyJV9mM4kxF5+geFgJih1DTtZS3qVafhHLB3e4l8omtvGftMnb8g==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-YcPczNLfPDB13eUBYHkTOkL7HyWqqqEhho4eSxhAvigZuxvtHQ1uyILIvLVAwipEVzhJ8QciKmLdLucpfi4XyA==", "cpu": [ "arm64" ], @@ -3317,9 +3317,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-qxYfv0aM4KCZPEe584KIjT5sO4uR+xdyuQXX5tXbnH1UoksIz7bvJ9KUgRloS/q/ww0f8UjPS2+27LnRA4y7ig==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-cHqkDg53xxxz21MThLBf4vx1kyIpRPEYNdEiQlvu9O35Tth49+aub6F+/YEMd9MG4TYZmxh1bEjkjErTUIElpA==", "cpu": [ "x64" ], @@ -3331,9 +3331,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-8gRAFx0ExDWHOmphl8mzBrSoGWnLWDU4VpxkPRsWqaJpHVbjr9Yk2QkuJNIaDmF6q44eJmW/huSiObmHTbZ1UQ==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-w26Gv9yq9LIYIhxjkQC+i0wBPDdQdX+H06ZhyVRL5grKWTIsk9Xwjp9mDRB/dGlXBKcvnM25JH16OyAA0rFH3A==", "cpu": [ "arm" ], @@ -3345,9 +3345,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-8G0BKvTkE+eKX1tSnyKeDaf3bWPWY7OI77SMipagCAyYi06v4gxx+IVE3Px7W7kLX2Wqp1MjWDXu2N76wfJtXQ==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-iHG0FEXq/QFsn+qlTPllxdcbvfQ9aRYggy4lc1z0+f11Nyk4YDNCSiR8WW7pbnOTx/VreGbbXhlpuJXTidqL8g==", "cpu": [ "arm64" ], @@ -3359,9 +3359,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-rsJV3Z9J/zYCEtcqvm+WfLAml3i1OAyMEUn0hja7i8C0kzE+tXKXzsJ0+I1TrSU5O7hHvqlLTvueBoCoM4aL4g==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-hMcUlUIzYbvbdq6j/B4RPL+kZR917NGnE9AgPZ7dJ92yamH/7LGT1Mnlc6McUx31yqTFBFHdTc7Cfx+ynua7Iw==", "cpu": [ "x64" ], @@ -3373,9 +3373,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-US1WsIu9IukaFzM+w8wt0fIAkmk2WtxeVuk8nkbrnH9S3ax39r0J4ikMNZSXEJE0VMxhXJoymzfWxhj3s9yW/Q==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-avJWIEKSx4rdBLZD1FOOTuxTU51dQfYb3jZvZMaXD4thJjq+6eSwfzu2elwL36AZDlnaxggGjB5nBxp0t54iOA==", "cpu": [ "arm64" ], @@ -3387,9 +3387,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20260306.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260306.1.tgz", - "integrity": "sha512-MlneT0RWS9Zdb8XoWvHsUgmnMJu6K3S0BXRu5ZgUYjcbQKlkz+Z87aUB8eX8qnDFd9csJcMp3+ZrgQ/LKVGP1g==", + "version": "7.0.0-dev.20260408.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260408.1.tgz", + "integrity": "sha512-gpvEHkF/WoxkA3711c4uWNCZO9WAuwrq49COdNwxgOTzYHnMc1yCj8CpkCUJwU0f/Ydwp2s6/efn6gTMvtckPg==", "cpu": [ "x64" ], @@ -19023,9 +19023,9 @@ } }, "node_modules/typescript": { - "version": "6.0.0-dev.20260306", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.0-dev.20260306.tgz", - "integrity": "sha512-ssxgK3/0yA2LEW23KzSNtnqSL9zDaVGTesx2S3EN+v8kqkPScFTin7S63KfQ4UDZGZGcvBgHCEoEz7t7v2yR8Q==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index f0ef74c50db59..7c9f41e5797db 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,7 @@ "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", - "@typescript/native-preview": "^7.0.0-dev.20260306", + "@typescript/native-preview": "^7.0.0-dev.20260408", "@vscode/component-explorer": "^0.2.1-8", "@vscode/component-explorer-cli": "^0.2.1-7", "@vscode/gulp-electron": "1.41.0", @@ -240,7 +240,7 @@ "tar": "^7.5.9", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^6.0.0-dev.20260306", + "typescript": "^6.0.0-dev.20260401", "typescript-eslint": "^8.45.0", "util": "^0.12.4", "xml2js": "^0.5.0", @@ -266,4 +266,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} From 7e35ba38ffd7dacc95b1eb96ad2f378b7665b1bc Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:23:33 -0400 Subject: [PATCH 02/15] sessions: fix isolation picker checked state (#308628) --- .../browser/isolationPicker.ts | 18 ++- .../test/browser/isolationPicker.test.ts | 110 ++++++++++++++++++ 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 src/vs/sessions/contrib/copilotChatSessions/test/browser/isolationPicker.test.ts diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts index bf4f3e30359ec..2405626c2daee 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts @@ -18,6 +18,11 @@ import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; export type IsolationMode = 'worktree' | 'workspace'; +interface IIsolationPickerItem { + readonly mode: IsolationMode; + readonly checked?: boolean; +} + /** * A self-contained widget for selecting the isolation mode. * @@ -114,31 +119,32 @@ export class IsolationPicker extends Disposable { return; } - const items: IActionListItem[] = [ + const currentIsolationMode = this._getSessionIsolationMode(); + const items: IActionListItem[] = [ { kind: ActionListItemKind.Action, label: localize('isolationMode.worktree', "Worktree"), group: { title: '', icon: Codicon.worktree }, - item: 'worktree', + item: { mode: 'worktree', checked: currentIsolationMode === 'worktree' || undefined }, }, { kind: ActionListItemKind.Action, label: localize('isolationMode.folder', "Folder"), group: { title: '', icon: Codicon.folder }, - item: 'workspace', + item: { mode: 'workspace', checked: currentIsolationMode === 'workspace' || undefined }, }, ]; const triggerElement = this._triggerElement; - const delegate: IActionListDelegate = { - onSelect: (mode) => { + const delegate: IActionListDelegate = { + onSelect: ({ mode }) => { this.actionWidgetService.hide(); this._setModeOnSession(mode); }, onHide: () => { triggerElement.focus(); }, }; - this.actionWidgetService.show( + this.actionWidgetService.show( 'isolationPicker', false, items, diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/isolationPicker.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/isolationPicker.test.ts new file mode 100644 index 0000000000000..88c1a3e487a66 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/isolationPicker.test.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IsolationMode, IsolationPicker } from '../../browser/isolationPicker.js'; + +interface IIsolationActionItem { + readonly mode: IsolationMode; + readonly checked?: boolean; +} + +function showPicker(container: HTMLElement): void { + const trigger = container.querySelector('a.action-label'); + assert.ok(trigger); + trigger.click(); +} + +function createPicker( + disposables: DisposableStore, + mode: IsolationMode, + actionWidgetItems: IActionListItem[], +): IsolationPicker { + const instantiationService = disposables.add(new TestInstantiationService()); + const activeSession = { + providerId: 'default-copilot', + sessionId: 'session-id', + loading: observableValue('loading', false), + } as unknown as IActiveSession; + const isolationMode = observableValue('isolationMode', mode); + const provider = { + getSession: () => ({ + gitRepository: {}, + isolationMode, + }), + }; + + instantiationService.stub(IActionWidgetService, { + isVisible: false, + hide: () => { }, + show: (_id: string, _supportsPreview: boolean, items: IActionListItem[]) => { + actionWidgetItems.splice(0, actionWidgetItems.length, ...(items as IActionListItem[])); + }, + }); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(ISessionsManagementService, { + activeSession: observableValue('activeSession', activeSession), + } as unknown as ISessionsManagementService); + instantiationService.stub(ISessionsProvidersService, { + onDidChangeProviders: Event.None, + getProviders: () => [], + getProvider: () => provider, + } as unknown as ISessionsProvidersService); + + return disposables.add(instantiationService.createInstance(IsolationPicker)); +} + +suite('IsolationPicker', () => { + const disposables = new DisposableStore(); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('marks folder as checked when workspace isolation is selected', () => { + const actionWidgetItems: IActionListItem[] = []; + const picker = createPicker(disposables, 'workspace', actionWidgetItems); + const container = document.createElement('div'); + picker.render(container); + showPicker(container); + + assert.deepStrictEqual( + actionWidgetItems.map(item => ({ label: item.label, checked: item.item?.checked })), + [ + { label: 'Worktree', checked: undefined }, + { label: 'Folder', checked: true }, + ], + ); + }); + + test('marks worktree as checked when worktree isolation is selected', () => { + const actionWidgetItems: IActionListItem[] = []; + const picker = createPicker(disposables, 'worktree', actionWidgetItems); + const container = document.createElement('div'); + picker.render(container); + showPicker(container); + + assert.deepStrictEqual( + actionWidgetItems.map(item => ({ label: item.label, checked: item.item?.checked })), + [ + { label: 'Worktree', checked: true }, + { label: 'Folder', checked: undefined }, + ], + ); + }); +}); From 791d6f2e502e973ca1df5099ae02b6d1955da190 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:24:57 -0400 Subject: [PATCH 03/15] sessions: darken light theme panel card borders (#308624) --- src/vs/sessions/LAYOUT.md | 9 +++++---- src/vs/sessions/browser/media/style.css | 6 ++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index a6a0236753522..c12d2ac8fcf24 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -420,14 +420,14 @@ Each agent session part uses separate storage keys to avoid conflicts with regul ### 9.5 Part Borders and Card Appearance -Parts manage their own border and background styling via the `updateStyles()` method. In the default light theme, the sessions workbench surface uses the off-white workbench/sidebar background while the card-like chat, auxiliary bar, and panel surfaces use the brighter editor background; dark and high-contrast mappings remain unchanged. The auxiliary bar and panel use a **card appearance** with CSS variables for background and border: +Parts manage their own border and background styling via the `updateStyles()` method. In the default light theme, the sessions workbench surface uses the off-white workbench/sidebar background while the card-like chat, auxiliary bar, and panel surfaces use the brighter editor background. Light themes also override the chat, auxiliary bar, and panel card border color in CSS to use `editorWidget.border`, giving those cards a darker outline. Dark and high-contrast mappings continue to use the existing part border tokens. These surfaces use a **card appearance** with CSS variables for background and border: | Part | Styling | Notes | |------|---------|-------| | Sidebar | Right border via `SIDE_BAR_BORDER` / `contrastBorder` | Flush appearance, no card styling | -| Chat Bar | Background only, no borders | `borderWidth` returns `0` | -| Auxiliary Bar | Card appearance via CSS variables `--part-background` / `--part-border-color` | Uses `sessionsAuxiliaryBarBackground` / `SIDE_BAR_BORDER`; default light themes map this card to `editorBackground`, while dark and high-contrast themes keep the existing sidebar-style surface; margins create card offset | -| Panel | Card appearance via CSS variables `--part-background` / `--part-border-color` | Uses `sessionsPanelBackground` / `PANEL_BORDER`; default light themes map this card to `editorBackground`, while dark and high-contrast themes keep the existing sidebar-style surface; margins create card offset | +| Chat Bar | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsChatBarBackground`; light themes map the card to `editorBackground` and darken the outline with `editorWidget.border`, while dark and high-contrast themes keep the existing part border token behavior; margins create card offset | +| Auxiliary Bar | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsAuxiliaryBarBackground` / `PANEL_BORDER`; default light themes map this card to `editorBackground` and darken the outline with `editorWidget.border`, while dark and high-contrast themes keep the existing sidebar-style surface; margins create card offset | +| Panel | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsPanelBackground` / `PANEL_BORDER`; default light themes map this card to `editorBackground` and darken the outline with `editorWidget.border`, while dark and high-contrast themes keep the existing sidebar-style surface; margins create card offset | --- @@ -646,6 +646,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-08 | Darkened the light-theme-only chat, auxiliary bar, and panel card borders with a sessions-specific CSS `border-color` override that uses `editorWidget.border`; dark and high-contrast themes continue using the existing part border tokens. | | 2026-04-04 | Inverted the default light-theme surface mapping so the sessions window background uses the off-white workbench/sidebar surface while the chat, changes, and panel cards use the brighter editor background; dark and high-contrast mappings remain unchanged. | | 2026-04-03 | Updated `SessionsTitleBarWidget` to format active session titles as `{Title} · {repo name} ({git branch/worktree name})` when repository detail metadata is available, falling back to the worktree folder name when needed. | | 2026-04-03 | Reduced the sessions left sidebar minimum resizable width from 270px to 170px so it can shrink significantly more while keeping the default 300px width unchanged | diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 0b75a7d078420..2ba614328a2db 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -64,6 +64,12 @@ background-color: var(--vscode-sessionsSidebar-background); } +.monaco-workbench.vs.agent-sessions-workbench .part.chatbar, +.monaco-workbench.vs.agent-sessions-workbench .part.auxiliarybar, +.monaco-workbench.vs.agent-sessions-workbench .part.panel { + border-color: var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); +} + /* ---- Chat Layout ---- */ /* Remove max-width from the session container so the scrollbar extends full width */ From 6afe18b48d11e7d12cfcd2174f859b7ee2319beb Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 8 Apr 2026 22:51:48 -0700 Subject: [PATCH 04/15] Clean up perf API (#308354) * wip * chat ext * PR --- .../copilot/src/util/common/performance.ts | 34 ++++++++++------ src/vs/base/common/performance.ts | 34 ++++++++-------- src/vs/base/test/common/performance.test.ts | 40 ++++++++++--------- .../workbench/contrib/chat/common/chatPerf.ts | 22 +++++++++- 4 files changed, 80 insertions(+), 50 deletions(-) diff --git a/extensions/copilot/src/util/common/performance.ts b/extensions/copilot/src/util/common/performance.ts index 3a7cd86c4a0c7..354fdf1ded3da 100644 --- a/extensions/copilot/src/util/common/performance.ts +++ b/extensions/copilot/src/util/common/performance.ts @@ -6,23 +6,15 @@ interface IMonacoPerformanceMarks { mark(name: string, markOptions?: { startTime?: number }): void; getMarks(): { name: string; startTime: number }[]; - clearMarks(prefix: string): void; + clearMarks(name?: string): void; } function _getNativePolyfill(): IMonacoPerformanceMarks { return { mark: (name, markOptions) => performance.mark(name, markOptions), getMarks: () => performance.getEntries().filter(e => e.entryType === 'mark').map(e => ({ name: e.name, startTime: e.startTime })), - clearMarks: prefix => { - const toRemove = new Set(); - for (const entry of performance.getEntries()) { - if (entry.entryType === 'mark' && entry.name.startsWith(prefix)) { - toRemove.add(entry.name); - } - } - for (const name of toRemove) { - performance.clearMarks(name); - } + clearMarks: name => { + performance.clearMarks(name); }, }; } @@ -31,6 +23,9 @@ const perf: IMonacoPerformanceMarks = (globalThis as { MonacoPerformanceMarks?: const chatExtPrefix = 'code/chat/ext/'; +/** Tracks all mark names emitted per session so they can be cleared individually. */ +const chatExtMarksBySession = new Map>(); + /** * Well-defined perf marks for the chat extension request lifecycle. * Each mark is a boundary of a measurable scenario — don't add marks @@ -87,7 +82,14 @@ export type ChatExtPerfMarkName = typeof ChatExtPerfMark[keyof typeof ChatExtPer */ export function markChatExt(sessionId: string | undefined, name: ChatExtPerfMarkName): void { if (sessionId) { - perf.mark(`${chatExtPrefix}${sessionId}/${name}`); + const fullName = `${chatExtPrefix}${sessionId}/${name}`; + let names = chatExtMarksBySession.get(sessionId); + if (!names) { + names = new Set(); + chatExtMarksBySession.set(sessionId, names); + } + names.add(fullName); + perf.mark(fullName); } } @@ -95,7 +97,13 @@ export function markChatExt(sessionId: string | undefined, name: ChatExtPerfMark * Clears all performance marks for the given chat session. */ export function clearChatExtMarks(sessionId: string): void { - perf.clearMarks(`${chatExtPrefix}${sessionId}/`); + const names = chatExtMarksBySession.get(sessionId); + if (names) { + for (const name of names) { + perf.clearMarks(name); + } + chatExtMarksBySession.delete(sessionId); + } } export const ChatExtGlobalPerfMark = { diff --git a/src/vs/base/common/performance.ts b/src/vs/base/common/performance.ts index 30cc655644ab2..418ae1a415794 100644 --- a/src/vs/base/common/performance.ts +++ b/src/vs/base/common/performance.ts @@ -24,10 +24,19 @@ function _definePolyfillMarks(timeOrigin?: number) { } return result; } - function clearMarks(prefix: string) { - for (let i = _data.length - 2; i >= 0; i -= 2) { - if (typeof _data[i] === 'string' && (_data[i] as string).startsWith(prefix)) { - _data.splice(i, 2); + function clearMarks(name?: string) { + if (typeof name === 'undefined') { + const hasTimeOrigin = _data.length >= 2 && _data[0] === 'code/timeOrigin'; + const timeOriginValue = hasTimeOrigin ? _data[1] : undefined; + _data.length = 0; + if (hasTimeOrigin) { + _data.push('code/timeOrigin', timeOriginValue); + } + } else { + for (let i = _data.length - 2; i >= 0; i -= 2) { + if (_data[i] === name) { + _data.splice(i, 2); + } } } } @@ -77,16 +86,8 @@ function _define() { mark(name: string, markOptions?: { startTime?: number }) { performance.mark(name, markOptions); }, - clearMarks(prefix: string) { - const toRemove = new Set(); - for (const entry of performance.getEntriesByType('mark')) { - if (entry.name.startsWith(prefix)) { - toRemove.add(entry.name); - } - } - for (const name of toRemove) { - performance.clearMarks(name); - } + clearMarks(name?: string) { + performance.clearMarks(name); }, getMarks() { let timeOrigin = performance.timeOrigin; @@ -132,9 +133,10 @@ const perf = _factory(globalThis); export const mark: (name: string, markOptions?: { startTime?: number }) => void = perf.mark; /** - * Clears all marks whose name starts with the given prefix. + * Clears performance marks. If a name is given, only marks with that exact + * name are removed. If no name is given, all marks are removed. */ -export const clearMarks: (prefix: string) => void = perf.clearMarks; +export const clearMarks: (name?: string) => void = perf.clearMarks; export interface PerformanceMark { readonly name: string; diff --git a/src/vs/base/test/common/performance.test.ts b/src/vs/base/test/common/performance.test.ts index 9e838693f8f34..6936f7b0f5b04 100644 --- a/src/vs/base/test/common/performance.test.ts +++ b/src/vs/base/test/common/performance.test.ts @@ -6,10 +6,6 @@ import assert from 'assert'; import { clearMarks, getMarks, mark } from '../../common/performance.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; -function marksFor(prefix: string) { - return getMarks().filter(m => m.name.startsWith(prefix)); -} - // Each test uses a unique prefix via a counter to avoid singleton state leaking between tests. let testCounter = 0; function uniquePrefix(): string { @@ -26,25 +22,31 @@ suite('clearMarks', () => { prefix = uniquePrefix(); }); - test('clears all marks with matching prefix', () => { - mark(`${prefix}a`); - mark(`${prefix}b`); - mark(`${prefix}c`); - - clearMarks(prefix); - assert.strictEqual(marksFor(prefix).length, 0); + teardown(() => { + clearMarks(); }); - test('does not clear marks with a different prefix', () => { - const otherPrefix = uniquePrefix(); - mark(`${prefix}a`); - mark(`${otherPrefix}b`); + test('clears a specific mark by exact name', () => { + const nameA = `${prefix}a`; + const nameB = `${prefix}b`; + mark(nameA); + mark(nameB); + + clearMarks(nameA); + + const remaining = getMarks().filter(m => m.name.startsWith(prefix)); + assert.deepStrictEqual(remaining.map(m => m.name), [nameB]); + }); - clearMarks(prefix); + test('does not clear marks with a different name', () => { + const name1 = `${prefix}a`; + const name2 = `${uniquePrefix()}b`; + mark(name1); + mark(name2); - assert.strictEqual(marksFor(prefix).length, 0); - assert.strictEqual(marksFor(otherPrefix).length, 1); + clearMarks(name1); - clearMarks(otherPrefix); + assert.strictEqual(getMarks().filter(m => m.name === name1).length, 0); + assert.strictEqual(getMarks().filter(m => m.name === name2).length, 1); }); }); diff --git a/src/vs/workbench/contrib/chat/common/chatPerf.ts b/src/vs/workbench/contrib/chat/common/chatPerf.ts index 37500e82bb5c9..276ea6d8d9303 100644 --- a/src/vs/workbench/contrib/chat/common/chatPerf.ts +++ b/src/vs/workbench/contrib/chat/common/chatPerf.ts @@ -9,6 +9,9 @@ import { chatSessionResourceToId } from './model/chatUri.js'; const chatPerfPrefix = 'code/chat/'; +/** Tracks all mark names emitted per session so they can be cleared individually. */ +const chatMarksBySession = new Map>(); + /** * Well-defined perf scenarios for chat request lifecycle. * Each mark is a boundary of a measurable scenario — don't add marks @@ -62,7 +65,15 @@ export const ChatPerfMark = { * disposed — see {@link clearChatMarks}. */ export function markChat(sessionResource: URI, name: string): void { - mark(`${chatPerfPrefix}${chatSessionResourceToId(sessionResource)}/${name}`); + const sessionId = chatSessionResourceToId(sessionResource); + const fullName = `${chatPerfPrefix}${sessionId}/${name}`; + let names = chatMarksBySession.get(sessionId); + if (!names) { + names = new Set(); + chatMarksBySession.set(sessionId, names); + } + names.add(fullName); + mark(fullName); } /** @@ -70,7 +81,14 @@ export function markChat(sessionResource: URI, name: string): void { * Called when the chat model is disposed. */ export function clearChatMarks(sessionResource: URI): void { - clearMarks(`${chatPerfPrefix}${chatSessionResourceToId(sessionResource)}/`); + const sessionId = chatSessionResourceToId(sessionResource); + const names = chatMarksBySession.get(sessionId); + if (names) { + for (const name of names) { + clearMarks(name); + } + chatMarksBySession.delete(sessionId); + } } /** From 634fd6e10cf629b735ca931f21582963531f7d5a Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 9 Apr 2026 06:29:08 +0000 Subject: [PATCH 05/15] Sessions - fix hasGitHubRemote state detection (#308677) --- src/vs/sessions/contrib/changes/browser/changesViewModel.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index 9afe0f62b11e1..37af4b637ef07 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -387,7 +387,9 @@ export class ChangesViewModel extends Disposable { // Pull request state const gitHubInfo = activeSession?.gitHubInfo.read(reader); - const hasGitHubRemote = hasGitHubRemotes(repositoryState!); + const hasGitHubRemote = repositoryState + ? hasGitHubRemotes(repositoryState) + : false; const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; const hasOpenPullRequest = hasPullRequest && (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || From 591f06894d34cd8bf14229edf2855ce860856d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Thu, 9 Apr 2026 09:48:18 +0200 Subject: [PATCH 06/15] Remove husky pre-commit and pre-push hooks; integrate copilot pre-commit checks directly in hygiene function (#308698) --- build/hygiene.ts | 13 +++++++++++++ extensions/copilot/.husky/pre-commit | 3 --- extensions/copilot/.husky/pre-push | 5 ----- extensions/copilot/package-lock.json | 17 ----------------- extensions/copilot/package.json | 2 -- 5 files changed, 13 insertions(+), 27 deletions(-) delete mode 100755 extensions/copilot/.husky/pre-commit delete mode 100755 extensions/copilot/.husky/pre-push diff --git a/build/hygiene.ts b/build/hygiene.ts index 936b0cbe63063..77d1a7730c120 100644 --- a/build/hygiene.ts +++ b/build/hygiene.ts @@ -290,6 +290,19 @@ if (import.meta.main) { const some = out.split(/\r?\n/).filter((l) => !!l); if (some.length > 0) { + // Run copilot pre-commit checks if copilot files are staged + if (some.some(f => f.startsWith('extensions/copilot/'))) { + console.log('Running copilot pre-commit checks...'); + const result = cp.spawnSync('npx', ['lint-staged'], { + cwd: path.join(process.cwd(), 'extensions', 'copilot'), + stdio: 'inherit', + }); + if (result.status !== 0) { + console.error('Copilot pre-commit checks failed'); + process.exit(1); + } + } + console.log('Reading git index versions...'); createGitIndexVinyls(some) diff --git a/extensions/copilot/.husky/pre-commit b/extensions/copilot/.husky/pre-commit deleted file mode 100755 index d9f4a9616c77e..0000000000000 --- a/extensions/copilot/.husky/pre-commit +++ /dev/null @@ -1,3 +0,0 @@ -set -e - -npx lint-staged diff --git a/extensions/copilot/.husky/pre-push b/extensions/copilot/.husky/pre-push deleted file mode 100755 index c6dfa742f5fb1..0000000000000 --- a/extensions/copilot/.husky/pre-push +++ /dev/null @@ -1,5 +0,0 @@ -set -e - -# git-lfs hook -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/pre-push'.\n"; exit 2; } -git lfs pre-push "$@" \ No newline at end of file diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index a5697198f05a9..3504830a495c7 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -110,7 +110,6 @@ "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", - "husky": "^9.1.7", "js-yaml": "^4.1.1", "keyv": "^5.3.2", "lint-staged": "15.2.9", @@ -13543,22 +13542,6 @@ "node": ">= 14" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 4970f6e278ca6..497e27ad182db 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6230,7 +6230,6 @@ }, "scripts": { "postinstall": "tsx ./script/postinstall.ts", - "prepare": "husky", "vscode-dts:update": "node script/build/vscodeDtsUpdate.js", "vscode-dts:check": "node script/build/vscodeDtsCheck.js", "vscode-dts:dev": "node node_modules/@vscode/dts/index.js dev && node script/build/moveProposedDts.js", @@ -6328,7 +6327,6 @@ "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", - "husky": "^9.1.7", "js-yaml": "^4.1.1", "keyv": "^5.3.2", "lint-staged": "15.2.9", From de2241ec33c9d419efd3e5dd6512e8cacb15ac60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Thu, 9 Apr 2026 09:54:40 +0200 Subject: [PATCH 07/15] Revert "Remove now unused `ChatSessionChangedFile` interface" (#308701) --- .../workbench/api/common/extHost.api.impl.ts | 1 + src/vs/workbench/api/common/extHostTypes.ts | 4 +++ .../vscode.proposed.chatSessionsProvider.d.ts | 29 ++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5b39d22ee90f5..0a3a9bc976e21 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2132,6 +2132,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection, ChatCopyKind: extHostTypes.ChatCopyKind, ChatSessionChangedFile: extHostTypes.ChatSessionChangedFile, + ChatSessionChangedFile2: extHostTypes.ChatSessionChangedFile2, ChatEditingSessionActionOutcome: extHostTypes.ChatEditingSessionActionOutcome, InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind, DebugStackFrame: extHostTypes.DebugStackFrame, diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 8f2008735ebbe..347d80f9be090 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3791,6 +3791,10 @@ export class ChatDebugEventHookContent { } export class ChatSessionChangedFile { + constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } +} + +export class ChatSessionChangedFile2 { constructor(public readonly uri: vscode.Uri, public readonly originalUri: vscode.Uri | undefined, public readonly modifiedUri: vscode.Uri | undefined, public readonly insertions: number, public readonly deletions: number) { } } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 58deb9b2d90ac..6a394c158b2ae 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -343,7 +343,7 @@ declare module 'vscode' { /** * Statistics about the chat session. */ - changes?: readonly ChatSessionChangedFile[]; + changes?: readonly ChatSessionChangedFile[] | readonly ChatSessionChangedFile2[]; /** * Arbitrary metadata for the chat session. Can be anything, but must be JSON-stringifyable. @@ -353,7 +353,34 @@ declare module 'vscode' { metadata?: { readonly [key: string]: any }; } + /** + * @deprecated Use `ChatSessionChangedFile2` instead + */ export class ChatSessionChangedFile { + /** + * URI of the file. + */ + modifiedUri: Uri; + + /** + * File opened when the user takes the 'compare' action. + */ + originalUri?: Uri; + + /** + * Number of insertions made during the session. + */ + insertions: number; + + /** + * Number of deletions made during the session. + */ + deletions: number; + + constructor(modifiedUri: Uri, insertions: number, deletions: number, originalUri?: Uri); + } + + export class ChatSessionChangedFile2 { /** * URI of the file. */ From 3ccf0b35438ab2e6d4a2ef34516a8234d845b19c Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:37:57 -0700 Subject: [PATCH 08/15] chat confirmation carousel rehaul v1 (#308679) * chat confirmation carousel rehaul v2 * address some comments on duplication * address some more comments --- .../chatSubagentContentPart.ts | 199 ++++++-- .../media/chatSubagentContent.css | 30 ++ .../media/chatToolConfirmationCarousel.css | 210 ++++---- .../chatToolConfirmationCarouselPart.ts | 482 +++++++++++------- .../chat/browser/widget/chatListRenderer.ts | 309 +++++++---- .../browser/widget/input/chatInputPart.ts | 47 +- 6 files changed, 833 insertions(+), 444 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index febd56bf63c74..6ef9bbf8d1575 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -9,7 +9,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; -import { IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { rcut } from '../../../../../../base/common/strings.js'; import { localize } from '../../../../../../nls.js'; @@ -107,6 +107,16 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private userManuallyExpanded: boolean = false; private autoExpandedForConfirmation: boolean = false; + // Carousel confirmation placeholder + private _navigateToCarousel: ((subAgentInvocationId: string) => void) | undefined; + private _addToolToCarousel: ((tool: IChatToolInvocation) => void) | undefined; + private _shouldUseCarouselForTool: ((tool: IChatToolInvocation, state: IChatToolInvocation.State) => boolean) | undefined; + private _confirmationPlaceholder: HTMLElement | undefined; + private _confirmationPlaceholderLabel: HTMLElement | undefined; + private readonly _confirmationPlaceholderDisposable = this._register(new MutableDisposable()); + private _useCarouselForConfirmations: boolean = false; + private toolsWaitingForCarouselConfirmation: number = 0; + // Working spinner elements for expanded state private workingSpinnerElement: HTMLElement | undefined; private workingSpinnerLabel: HTMLElement | undefined; @@ -189,7 +199,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen const initialTitle = `${prefix}: ${description}`; super(initialTitle, context, undefined, hoverService, configurationService); - this.description = description; + this.description = rcut(description, MAX_TITLE_LENGTH); this._isDefaultDescription = isDefaultDescription; this.agentName = agentName; this.prompt = prompt; @@ -311,15 +321,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } } - /** - * Returns the insertion anchor for appending items to the wrapper. - * Items should be inserted before the working spinner (if present), - * then before the result container (if present), otherwise appended. - */ - private getInsertionAnchor(): HTMLElement | undefined { - return this.workingSpinnerElement ?? this.resultContainer; - } - protected override initContent(): HTMLElement { this.wrapper = $('.chat-used-context-list.chat-thinking-collapsible'); @@ -331,10 +332,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Materialize any deferred content now that wrapper exists // This handles the case where the subclass autorun ran before this base class autorun this.materializePendingContent(); - - // Create working spinner if still active and no confirmations pending - if (!this.isInitiallyComplete && this.isActive && this.toolsWaitingForConfirmation === 0) { - this.createWorkingSpinner(); + if (this.isActive && !this.isInitiallyComplete && !this.hasToolsWaitingForConfirmation) { + this.showWorkingSpinner(); } // Use ResizeObserver to trigger layout when wrapper content changes @@ -422,6 +421,28 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen return this.toolsWaitingForConfirmation > 0; } + /** Routes this subagent's initial confirmations to the input carousel. */ + public enableCarouselMode( + navigateToCarousel: (subAgentInvocationId: string) => void, + addToolToCarousel: (tool: IChatToolInvocation) => void, + shouldUseCarouselForTool: (tool: IChatToolInvocation, state: IChatToolInvocation.State) => boolean, + ): void { + this._useCarouselForConfirmations = true; + this._navigateToCarousel = navigateToCarousel; + this._addToolToCarousel = addToolToCarousel; + this._shouldUseCarouselForTool = shouldUseCarouselForTool; + } + + public getAgentLabel(): string { + if (this.agentName) { + return this.agentName; + } + if (!this._isDefaultDescription && this.description) { + return this.description; + } + return localize('chat.subagent.prefix', 'Subagent'); + } + public markAsInactive(): void { this.isActive = false; this.domNode.classList.remove('chat-thinking-active'); @@ -430,6 +451,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } this.removeWorkingSpinner(); + this.hideConfirmationPlaceholder(); if (this._isDefaultDescription) { this.description = localize('chat.subagent.completedDefaultDescription', 'Ran subagent'); @@ -540,17 +562,19 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen const messageText = typeof message === 'string' ? message : message.value; this.currentRunningToolMessage = messageText; this.updateTitle(); + const addToolToCarousel = this._addToolToCarousel; + const shouldUseCarouselForTool = this._shouldUseCarouselForTool; let wasWaitingForConfirmation = false; + let wasWaitingForCarouselConfirmation = false; this._register(autorun(r => { const state = toolInvocation.state.read(r); - // Track confirmation state changes const isWaitingForConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; + const isWaitingForCarouselConfirmation = !!addToolToCarousel && shouldUseCarouselForTool?.(toolInvocation, state) === true; if (isWaitingForConfirmation && !wasWaitingForConfirmation) { - // Tool just started waiting for confirmation this.toolsWaitingForConfirmation++; if (!this.isExpanded()) { this.autoExpandedForConfirmation = true; @@ -559,7 +583,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Remove the working spinner while confirmation is shown this.removeWorkingSpinner(); } else if (!isWaitingForConfirmation && wasWaitingForConfirmation) { - // Tool is no longer waiting for confirmation this.toolsWaitingForConfirmation--; if (this.toolsWaitingForConfirmation === 0 && this.autoExpandedForConfirmation && !this.userManuallyExpanded) { // Auto-collapse only if we auto-expanded and user didn't manually expand @@ -572,10 +595,95 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } } + if (isWaitingForCarouselConfirmation && !wasWaitingForCarouselConfirmation) { + this.toolsWaitingForCarouselConfirmation++; + addToolToCarousel(toolInvocation); + this.showConfirmationPlaceholder(); + } else if (!isWaitingForCarouselConfirmation && wasWaitingForCarouselConfirmation) { + this.toolsWaitingForCarouselConfirmation--; + if (this.toolsWaitingForCarouselConfirmation === 0) { + this.hideConfirmationPlaceholder(); + } else { + this.updateConfirmationPlaceholderLabel(); + } + } + wasWaitingForConfirmation = isWaitingForConfirmation; + wasWaitingForCarouselConfirmation = isWaitingForCarouselConfirmation; })); } + private getConfirmationPlaceholderText(): string { + const count = this.toolsWaitingForCarouselConfirmation; + return count === 1 + ? localize('chat.subagent.pendingConfirmation', '1 pending confirmation') + : localize('chat.subagent.pendingConfirmations', '{0} pending confirmations', count); + } + + private updateConfirmationPlaceholderLabel(): void { + if (this._confirmationPlaceholderLabel) { + this._confirmationPlaceholderLabel.textContent = this.getConfirmationPlaceholderText(); + } + } + + /** Shows a placeholder that jumps back to the carousel. */ + private showConfirmationPlaceholder(): void { + if (this._confirmationPlaceholder) { + this.updateConfirmationPlaceholderLabel(); + return; + } + + const placeholder = $('button.chat-subagent-confirmation-placeholder'); + const label = $('span.chat-subagent-placeholder-label'); + label.textContent = this.getConfirmationPlaceholderText(); + placeholder.appendChild(label); + + this._confirmationPlaceholder = placeholder; + this._confirmationPlaceholderLabel = label; + + const placeholderDisposables = new DisposableStore(); + placeholderDisposables.add(dom.addDisposableListener(placeholder, 'click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._navigateToCarousel?.(this.subAgentInvocationId); + })); + this._confirmationPlaceholderDisposable.value = placeholderDisposables; + + if (!this.hasToolItems) { + this.hasToolItems = true; + if (this.wrapper) { + this.wrapper.style.display = ''; + } + } + + if (!this.isExpanded()) { + this.autoExpandedForConfirmation = true; + this.setExpanded(true); + } + + if (this.wrapper) { + this.wrapper.appendChild(placeholder); + } + this.layoutScheduler.schedule(); + } + + private hideConfirmationPlaceholder(): void { + if (this._confirmationPlaceholder) { + this._confirmationPlaceholder.remove(); + this._confirmationPlaceholder = undefined; + this._confirmationPlaceholderLabel = undefined; + this._confirmationPlaceholderDisposable.clear(); + this.layoutScheduler.schedule(); + } + } + + /** Keeps the carousel placeholder after visible tool output. */ + private ensurePlaceholderAtBottom(): void { + if (this._confirmationPlaceholder?.parentElement === this.wrapper) { + this.wrapper.appendChild(this._confirmationPlaceholder); + } + } + /** * Watches the tool invocation for completion and renders the result. * Handles both live and serialized invocations. @@ -723,21 +831,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Track tool state for title updates and auto-expand/collapse on confirmation this.trackToolState(toolInvocation); - // Update working spinner label with a new random message - if (this.workingSpinnerLabel) { - this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(); - } - - // Ensure expanded when a tool needing confirmation is appended (e.g. after session switch) - if (toolInvocation.kind === 'toolInvocation') { - const state = toolInvocation.state.get(); - if ((state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || - state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) && !this.isExpanded()) { - this.autoExpandedForConfirmation = true; - this.setExpanded(true); - } - } - // Render immediately only if already expanded or has been expanded before if (this.isExpanded() || this.hasExpandedOnce) { const part = this.createToolPart(toolInvocation, codeBlockStartIndex); @@ -834,9 +927,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } if (this.wrapper) { - const anchor = this.getInsertionAnchor(); - if (anchor) { - this.wrapper.insertBefore(itemWrapper, anchor); + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); } else { this.wrapper.appendChild(itemWrapper); } @@ -859,11 +951,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen itemWrapper.appendChild(domNode); itemWrapper.insertBefore(iconElement, itemWrapper.firstChild); - // Insert before spinner/result container if either exists, otherwise append + // Insert before result container if it exists, otherwise append if (this.wrapper) { - const anchor = this.getInsertionAnchor(); - if (anchor) { - this.wrapper.insertBefore(itemWrapper, anchor); + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); } else { this.wrapper.appendChild(itemWrapper); } @@ -916,14 +1007,28 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Dynamically add/remove icon based on confirmation state if (toolInvocation.kind === 'toolInvocation') { + const shouldUseCarouselForTool = this._shouldUseCarouselForTool; this._register(autorun(r => { const state = toolInvocation.state.read(r); const hasConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; + const shouldHideInline = shouldUseCarouselForTool?.(toolInvocation, state) === true; if (hasConfirmation) { iconElement.remove(); - } else if (!iconElement.parentElement) { - itemWrapper.insertBefore(iconElement, itemWrapper.firstChild); + if (shouldHideInline) { + itemWrapper.style.display = 'none'; + } else { + itemWrapper.style.display = ''; + } + } else { + if (!iconElement.parentElement) { + itemWrapper.insertBefore(iconElement, itemWrapper.firstChild); + } + if (this._useCarouselForConfirmations) { + itemWrapper.style.display = ''; + // Re-position the confirmation placeholder to stay at the bottom + this.ensurePlaceholderAtBottom(); + } } })); } else { @@ -931,10 +1036,9 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen itemWrapper.insertBefore(iconElement, itemWrapper.firstChild); } - // Insert before spinner/result container if either exists, otherwise append - // With lazy rendering, wrapper may not be created yet if content hasn't been expanded + // Keep newly-visible tool results above the placeholder/spinner. if (this.wrapper) { - const anchor = this.getInsertionAnchor(); + const anchor = this._confirmationPlaceholder ?? this.workingSpinnerElement ?? this.resultContainer; if (anchor) { this.wrapper.insertBefore(itemWrapper, anchor); } else { @@ -995,11 +1099,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.materializeLazyItem(item); } - // Create working spinner if still active, no confirmations pending, and not yet created - if (!this.isInitiallyComplete && this.isActive && this.toolsWaitingForConfirmation === 0 && !this.workingSpinnerElement) { - this.createWorkingSpinner(); - } - // Render pending result text if (this.pendingResultText) { const resultText = this.pendingResultText; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css index b1e8dd591c00e..1db44f94d05b3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css @@ -60,4 +60,34 @@ } } } + + button.chat-subagent-confirmation-placeholder { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px 6px 24px; + background: none; + border: none; + outline: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: var(--vscode-chat-font-size-body-s); + position: relative; + + &::before { + content: '\2022'; + position: absolute; + left: 8px; + color: var(--vscode-descriptionForeground); + } + + &:hover { + color: var(--vscode-textLink-foreground); + } + + &:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; + } + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css index 1a7c2223b1c7f..8ab73acb9b684 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css @@ -24,6 +24,10 @@ display: flex; flex-direction: column; max-height: min(420px, 45vh); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: var(--vscode-cornerRadius-large); + background-color: var(--vscode-panel-background); + overflow: hidden; a { color: var(--vscode-foreground); @@ -34,108 +38,119 @@ } } - /* --- Header (tabs + bulk actions) — only visible when multiple items --- */ - - .chat-tool-carousel-header { + .chat-tool-carousel-overlay { display: flex; align-items: center; - flex-shrink: 0; - min-height: 0; - background-color: var(--vscode-panel-background); - border: 1px solid var(--vscode-input-border, transparent); - border-bottom: none; - border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; + gap: 8px; + padding: 6px 10px 6px 12px; + border-bottom: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-border, transparent)); } - .chat-tool-carousel-tabs { - position: relative; + .chat-tool-carousel-title-group { display: flex; - flex: 1; + align-items: center; + gap: 6px; min-width: 0; - overflow-x: auto; - gap: 0; - mask-image: linear-gradient(to right, transparent, black 12px, black calc(100% - 12px), transparent); + overflow: hidden; + } - &::-webkit-scrollbar { - height: 3px; - } + .chat-tool-carousel-collapsed-title { + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + white-space: nowrap; + color: var(--vscode-foreground); + } - /* When scrolled to the start, no left fade */ - &.scroll-start { - mask-image: linear-gradient(to right, black, black calc(100% - 12px), transparent); - } + .chat-tool-carousel-agent-label { + background: none; + border: none; + cursor: pointer; + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-textLink-foreground); + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; - /* When scrolled to the end, no right fade */ - &.scroll-end { - mask-image: linear-gradient(to right, transparent, black 12px, black); + &:hover { + text-decoration: underline; } - /* When at both start and end (no overflow), no fades */ - &.scroll-start.scroll-end { - mask-image: none; + &:disabled { + cursor: default; } } - .chat-tool-carousel-tab { + .chat-tool-carousel-overlay-actions { display: flex; align-items: center; - gap: 6px; - padding: 8px 14px; - cursor: pointer; - font-size: 12px; - border: none; - border-right: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-border, transparent)); - background: transparent; - color: var(--vscode-descriptionForeground); - max-width: 200px; + gap: 4px; flex-shrink: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: background-color 0.1s; - - &:last-child { - border-right: none; - } + margin-left: auto; + } - &:hover { - background-color: var(--vscode-list-hoverBackground); - color: var(--vscode-foreground); - } + .chat-tool-carousel-nav-arrows { + display: flex; + align-items: center; + } - &.active { - background-color: var(--vscode-panel-background); - color: var(--vscode-foreground); - position: relative; + .monaco-button.chat-tool-carousel-nav-arrow { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-foreground) !important; + } - &::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background-color: var(--vscode-focusBorder); - border-radius: 1px; - z-index: 1; - } - } + .monaco-button.chat-tool-carousel-nav-arrow:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } - .chat-tool-carousel-tab-label { - overflow: hidden; - text-overflow: ellipsis; - } + .monaco-button.chat-tool-carousel-nav-arrow.disabled { + opacity: 0.4; } - .chat-tool-carousel-bulk-actions { + .monaco-button.chat-tool-carousel-header-button { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-icon-foreground) !important; + cursor: pointer; display: flex; - gap: 4px; - flex-shrink: 0; - padding: 4px 8px; - white-space: nowrap; + align-items: center; + justify-content: center; + } + + .monaco-button.chat-tool-carousel-header-button:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; } - /* --- Content area — always styled the same regardless of single/multi --- */ + &.chat-tool-carousel-collapsed { + .chat-tool-carousel-content { + display: none; + } + + .chat-tool-carousel-overlay { + padding: 6px 10px 6px 12px; + border-bottom: none; + } + + .chat-tool-carousel-title-group { + flex: 1; + } + + .chat-tool-carousel-nav-arrows, + .chat-tool-carousel-step-indicator { + display: none; + } + } .chat-tool-carousel-content { display: flex; @@ -143,9 +158,6 @@ min-height: 0; overflow: hidden; flex: 1; - border: 1px solid var(--vscode-input-border, transparent); - border-radius: var(--vscode-cornerRadius-large); - background-color: var(--vscode-panel-background); .chat-tool-invocation-part { margin: 0; @@ -164,15 +176,26 @@ } .chat-confirmation-widget-message-container { + display: flex; + flex-direction: column; border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-border, transparent)); border-radius: var(--vscode-cornerRadius-medium); overflow: hidden; } - .interactive-result-editor { - background-color: var(--vscode-sideBar-background); + .chat-confirmation-widget-message { + overflow: auto; + max-height: min(200px, 30vh); } + .chat-confirmation-message-terminal-editor .interactive-result-code-block { + max-height: min(150px, 25vh); + overflow: auto; + } + + .chat-buttons-container { + flex-shrink: 0; + } .interactive-result-code-block { border: none; border-radius: 0; @@ -206,11 +229,8 @@ align-items: center; } - .chat-confirmation-widget-title p { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0 4px; + .chat-confirmation-widget-title { + display: none !important; } .chat-confirmation-message-terminal .chat-confirmation-message-terminal-disclaimer p:last-child { @@ -221,9 +241,19 @@ } } - /* When header is visible (multi-item), connect content to header visually */ - &:not(.single-item) .chat-tool-carousel-content { - border-top: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-border, transparent)); - border-radius: 0 0 var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large); + .chat-tool-carousel-step-indicator { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + white-space: nowrap; + padding: 0 4px; + } +} + +.agent-sessions-workbench .chat-tool-confirmation-carousel, +.editor-instance .chat-tool-confirmation-carousel { + background-color: var(--vscode-editor-background); + + .interactive-result-editor { + background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background)); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts index 48e166a4bd006..54ead8692b57e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts @@ -6,25 +6,30 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../../base/common/observable.js'; import { localize } from '../../../../../../../nls.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; -import { migrateLegacyTerminalToolSpecificData } from '../../../../common/chat.js'; import { IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; -import { ILanguageModelToolsService } from '../../../../common/tools/languageModelToolsService.js'; import { ChatToolInvocationPart } from './chatToolInvocationPart.js'; import '../media/chatToolConfirmationCarousel.css'; export type ToolInvocationPartFactory = (tool: IChatToolInvocation) => ChatToolInvocationPart; +export type ScrollToSubagentCallback = (subAgentInvocationId: string) => void; + interface ICarouselToolItem { readonly tool: IChatToolInvocation; readonly toolCallId: string; - readonly tabElement: HTMLElement; readonly disposables: DisposableStore; + readonly subAgentInvocationId?: string; + readonly agentName?: string; + readonly scrollToSubagent?: ScrollToSubagentCallback; + ownsToolPart: boolean; toolPart?: ChatToolInvocationPart; } @@ -38,46 +43,88 @@ export class ChatToolConfirmationCarouselPart extends Disposable { private readonly toolCallIds = new Set(); private activeIndex = 0; - private readonly headerElement: HTMLElement; - private readonly tabsContainer: HTMLElement; + private readonly collapsedTitle: HTMLElement; + private readonly agentLabel: HTMLButtonElement; private readonly contentContainer: HTMLElement; + private readonly stepIndicator: HTMLElement; + private readonly prevButton: Button; + private readonly nextButton: Button; + private readonly allowAllButton: Button; + private readonly collapseButton: Button; + private _isCollapsed = false; constructor( private readonly toolPartFactory: ToolInvocationPartFactory, initialTools: IChatToolInvocation[], - private readonly toolsService: ILanguageModelToolsService, + private readonly scrollToSubagent?: ScrollToSubagentCallback, + private readonly initialSubAgentInvocationId?: string, + private readonly initialAgentName?: string, ) { super(); const elements = dom.h('.chat-tool-confirmation-carousel@root', [ - dom.h('.chat-tool-carousel-header@header', [ - dom.h('.chat-tool-carousel-tabs@tabs'), - dom.h('.chat-tool-carousel-bulk-actions@bulkActions'), + dom.h('.chat-tool-carousel-overlay@overlay', [ + dom.h('.chat-tool-carousel-title-group@titleGroup', [ + dom.h('span.chat-tool-carousel-collapsed-title@collapsedTitle'), + dom.h('button.chat-tool-carousel-agent-label@agentLabel'), + ]), + dom.h('.chat-tool-carousel-overlay-actions@overlayActions', [ + dom.h('.chat-tool-carousel-step-indicator@stepIndicator'), + dom.h('.chat-tool-carousel-nav-arrows@navArrows'), + ]), ]), dom.h('.chat-tool-carousel-content@content'), ]); this.domNode = elements.root; - this.headerElement = elements.header; - this.tabsContainer = elements.tabs; + this.domNode.tabIndex = -1; + this.domNode.setAttribute('role', 'group'); + this.domNode.setAttribute('aria-label', localize('toolConfirmationCarousel', "Tool confirmation carousel")); + this.collapsedTitle = elements.collapsedTitle; + this.agentLabel = elements.agentLabel; this.contentContainer = elements.content; + this.stepIndicator = elements.stepIndicator; + + this.allowAllButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, small: true })); + this.allowAllButton.element.classList.add('chat-tool-carousel-allow-all-button'); + this.allowAllButton.label = localize('allowAll', "Allow All"); + this._register(this.allowAllButton.onDidClick(() => this.allowAll())); + + this.collapseButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + this.collapseButton.element.classList.add('chat-tool-carousel-header-button'); + this.collapseButton.label = `$(${Codicon.chevronDown.id})`; + this.collapseButton.element.setAttribute('aria-label', localize('collapse', "Collapse")); + this._register(this.collapseButton.onDidClick(() => this.toggleCollapse())); + + this.prevButton = this._register(new Button(elements.navArrows, { + ...defaultButtonStyles, + secondary: true, + supportIcons: true, + })); + this.prevButton.element.classList.add('chat-tool-carousel-nav-arrow'); + this.prevButton.label = `$(${Codicon.chevronLeft.id})`; + this.prevButton.element.setAttribute('aria-label', localize('previous', "Previous")); + this._register(this.prevButton.onDidClick(() => this.navigateRelative(-1))); + + this.nextButton = this._register(new Button(elements.navArrows, { + ...defaultButtonStyles, + secondary: true, + supportIcons: true, + })); + this.nextButton.element.classList.add('chat-tool-carousel-nav-arrow'); + this.nextButton.label = `$(${Codicon.chevronRight.id})`; + this.nextButton.element.setAttribute('aria-label', localize('next', "Next")); + this._register(this.nextButton.onDidClick(() => this.navigateRelative(1))); + + this._register(dom.addDisposableListener(this.agentLabel, 'click', e => { + e.preventDefault(); + this.scrollToActiveSubagent(); + })); - // Header: bulk Allow All / Skip All - const allowAllBtn = this._register(new Button(elements.bulkActions, { ...defaultButtonStyles, small: true })); - allowAllBtn.label = localize('allowAll', "Allow All"); - this._register(allowAllBtn.onDidClick(() => this.allowAll())); - - const skipAllBtn = this._register(new Button(elements.bulkActions, { ...defaultButtonStyles, small: true, secondary: true })); - skipAllBtn.label = localize('skipAll', "Skip All"); - this._register(skipAllBtn.onDidClick(() => this.skipAll())); - - // Track scroll position for fade indicators - this._updateTabsScrollClasses(); - this._register(dom.addDisposableListener(this.tabsContainer, 'scroll', () => this._updateTabsScrollClasses())); + this._register(dom.addDisposableListener(this.domNode, 'keydown', e => this.onKeydown(e))); - // Add initial tools for (const tool of initialTools) { - this.addToolInvocation(tool); + this.addToolInvocation(tool, this.initialSubAgentInvocationId, this.initialAgentName, this.scrollToSubagent); } } @@ -89,8 +136,12 @@ export class ChatToolConfirmationCarouselPart extends Disposable { return this.toolCallIds.has(toolCallId); } - addToolInvocation(tool: IChatToolInvocation): void { + addToolInvocation(tool: IChatToolInvocation, subAgentInvocationId?: string, agentName?: string, scrollToSubagent?: ScrollToSubagentCallback, toolPart?: ChatToolInvocationPart): void { if (this.toolCallIds.has(tool.toolCallId)) { + const existing = this.items.find(item => item.toolCallId === tool.toolCallId); + if (existing && toolPart) { + this.replaceExternalToolPart(existing, toolPart); + } return; } @@ -98,73 +149,21 @@ export class ChatToolConfirmationCarouselPart extends Disposable { const disposables = new DisposableStore(); - // Create the tab as a button with proper ARIA semantics - const tabLabel = this.getTabLabel(tool); - const tabElement = dom.$('button.chat-tool-carousel-tab', { role: 'tab', 'aria-selected': 'false', tabIndex: 0 }); - const labelEl = dom.$('.chat-tool-carousel-tab-label'); - labelEl.textContent = tabLabel; - tabElement.appendChild(labelEl); - - const selectTab = () => { - const idx = this.items.findIndex(i => i.toolCallId === tool.toolCallId); - if (idx >= 0) { - this.setActiveIndex(idx); - } - }; - - const navigateToTab = (targetIndex: number) => { - if (targetIndex < 0 || targetIndex >= this.items.length) { - return; - } - this.setActiveIndex(targetIndex); - this.items[targetIndex].tabElement.focus(); - }; - - disposables.add(dom.addDisposableListener(tabElement, 'click', selectTab)); - disposables.add(dom.addDisposableListener(tabElement, 'keydown', (e: KeyboardEvent) => { - const event = new StandardKeyboardEvent(e); - if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) { - e.preventDefault(); - selectTab(); - return; - } - - const currentIndex = this.items.findIndex(i => i.toolCallId === tool.toolCallId); - if (currentIndex < 0) { - return; - } - - switch (event.keyCode) { - case KeyCode.LeftArrow: - e.preventDefault(); - navigateToTab((currentIndex + this.items.length - 1) % this.items.length); - break; - case KeyCode.RightArrow: - e.preventDefault(); - navigateToTab((currentIndex + 1) % this.items.length); - break; - case KeyCode.Home: - e.preventDefault(); - navigateToTab(0); - break; - case KeyCode.End: - e.preventDefault(); - navigateToTab(this.items.length - 1); - break; - } - })); - - this.tabsContainer.appendChild(tabElement); - const item: ICarouselToolItem = { tool, toolCallId: tool.toolCallId, - tabElement, disposables, + subAgentInvocationId, + agentName, + scrollToSubagent, + ownsToolPart: !toolPart, + toolPart, }; this.items.push(item); + if (toolPart) { + this.watchExternalToolPart(item, toolPart); + } - // Watch tool state — remove from carousel when no longer waiting disposables.add(autorun(reader => { const currentState = tool.state.read(reader); if (currentState.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { @@ -172,14 +171,59 @@ export class ChatToolConfirmationCarouselPart extends Disposable { } })); - this.updateVisibility(); + this.updateUI(); - // If this is the first item, render its content if (this.items.length === 1) { this.setActiveIndex(0); } } + private replaceExternalToolPart(item: ICarouselToolItem, toolPart: ChatToolInvocationPart): void { + if (item.toolPart === toolPart) { + return; + } + + if (item.toolPart && item.ownsToolPart) { + item.toolPart.dispose(); + } + + item.toolPart = toolPart; + item.ownsToolPart = false; + this.watchExternalToolPart(item, toolPart); + if (this.items[this.activeIndex] === item) { + this.renderActiveContent(); + } + } + + private watchExternalToolPart(item: ICarouselToolItem, toolPart: ChatToolInvocationPart): void { + let isItemAlive = true; + item.disposables.add(toDisposable(() => isItemAlive = false)); + + toolPart.addDisposable(toDisposable(() => { + if (!isItemAlive || item.toolPart !== toolPart) { + return; + } + + item.toolPart = undefined; + item.ownsToolPart = true; + if (this.items[this.activeIndex] === item) { + this.renderActiveContent(); + } + })); + } + + override dispose(): void { + for (const item of this.items) { + if (item.toolPart && item.ownsToolPart) { + item.toolPart.dispose(); + } + item.disposables.dispose(); + } + this.items.splice(0); + this.toolCallIds.clear(); + super.dispose(); + } + private removeItem(toolCallId: string): void { const index = this.items.findIndex(i => i.toolCallId === toolCallId); if (index < 0) { @@ -188,8 +232,7 @@ export class ChatToolConfirmationCarouselPart extends Disposable { const [removed] = this.items.splice(index, 1); this.toolCallIds.delete(toolCallId); - removed.tabElement.remove(); - if (removed.toolPart) { + if (removed.toolPart && removed.ownsToolPart) { removed.toolPart.dispose(); } removed.disposables.dispose(); @@ -200,48 +243,134 @@ export class ChatToolConfirmationCarouselPart extends Disposable { return; } - // Adjust active index if (this.activeIndex >= this.items.length) { this.activeIndex = this.items.length - 1; } - this.updateVisibility(); - this.updateTabs(); + this.updateUI(); this.renderActiveContent(); } private setActiveIndex(index: number): void { this.activeIndex = index; - this.updateTabs(); + this.updateUI(); this.renderActiveContent(); } - /** - * Show/hide multi-item elements (bulk actions, nav, tabs overflow) - * based on whether there's more than one item. - */ - private updateVisibility(): void { - const multi = this.items.length > 1; - dom.setVisibility(multi, this.headerElement); - this.domNode.classList.toggle('single-item', !multi); - this.updateTabs(); + private navigateRelative(delta: number): void { + if (this.items.length <= 1) { + return; + } + const newIndex = (this.activeIndex + delta + this.items.length) % this.items.length; + this.setActiveIndex(newIndex); } - private updateTabs(): void { - for (let i = 0; i < this.items.length; i++) { - const isActive = i === this.activeIndex; - this.items[i].tabElement.classList.toggle('active', isActive); - this.items[i].tabElement.setAttribute('aria-selected', String(isActive)); + private onKeydown(e: KeyboardEvent): void { + if (this.items.length === 0) { + return; + } + + if (this.shouldIgnoreNavigationKeydown(e.target)) { + return; + } + + const event = new StandardKeyboardEvent(e); + const focusContentAfterNavigation = dom.isHTMLElement(e.target) && this.contentContainer.contains(e.target); + let didNavigate = false; + + switch (event.keyCode) { + case KeyCode.LeftArrow: + this.navigateRelative(-1); + didNavigate = true; + break; + case KeyCode.RightArrow: + this.navigateRelative(1); + didNavigate = true; + break; + case KeyCode.Home: + this.setActiveIndex(0); + didNavigate = true; + break; + case KeyCode.End: + this.setActiveIndex(this.items.length - 1); + didNavigate = true; + break; + } + + if (!didNavigate) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (focusContentAfterNavigation) { + this.focusActiveContent(); } - this._updateTabsScrollClasses(); } - private _updateTabsScrollClasses(): void { - const el = this.tabsContainer; - const atStart = el.scrollLeft <= 0; - const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1; - el.classList.toggle('scroll-start', atStart); - el.classList.toggle('scroll-end', atEnd); + private shouldIgnoreNavigationKeydown(target: EventTarget | null): boolean { + if (!dom.isHTMLElement(target)) { + return false; + } + + return !!target.closest('.monaco-editor, .interactive-result-editor, .chat-confirmation-widget-message, input, textarea, select, [contenteditable="true"]'); + } + + private focusActiveContent(): void { + this.domNode.focus(); + } + + private toggleCollapse(): void { + this._isCollapsed = !this._isCollapsed; + this.domNode.classList.toggle('chat-tool-carousel-collapsed', this._isCollapsed); + this.collapseButton.label = this._isCollapsed + ? `$(${Codicon.chevronUp.id})` + : `$(${Codicon.chevronDown.id})`; + this.collapseButton.element.setAttribute('aria-label', + this._isCollapsed ? localize('expand', "Expand") : localize('collapse', "Collapse") + ); + this.updateUI(); + } + + private updateUI(): void { + const item = this.items[this.activeIndex]; + + if (this._isCollapsed) { + this.collapsedTitle.textContent = this.items.length === 1 + ? localize('confirmTool', "Confirm tool?") + : localize('confirmTools', "Confirm {0} tools?", this.items.length); + } else { + this.collapsedTitle.textContent = this.getToolTitle(item) ?? ''; + } + dom.setVisibility(this._isCollapsed || !!this.collapsedTitle.textContent, this.collapsedTitle); + + if (item?.agentName) { + this.agentLabel.textContent = `\u2014 ${item.agentName}`; + this.agentLabel.disabled = !item.subAgentInvocationId || !item.scrollToSubagent; + this.agentLabel.title = localize('scrollToSubagent', "Scroll to {0}", item.agentName); + this.agentLabel.setAttribute('aria-label', this.agentLabel.title); + dom.show(this.agentLabel); + } else { + this.agentLabel.textContent = ''; + this.agentLabel.title = ''; + this.agentLabel.removeAttribute('aria-label'); + dom.hide(this.agentLabel); + } + + this.stepIndicator.textContent = `${this.activeIndex + 1}/${this.items.length}`; + + const multi = this.items.length > 1; + this.prevButton.enabled = multi; + this.nextButton.enabled = multi; + dom.setVisibility(multi, this.stepIndicator); + dom.setVisibility(multi, this.prevButton.element); + dom.setVisibility(multi, this.nextButton.element); + dom.setVisibility(this._isCollapsed || multi, this.allowAllButton.element); + + this.allowAllButton.label = multi + ? localize('allowAll', "Allow All") + : localize('allow', "Allow"); } private renderActiveContent(): void { @@ -252,107 +381,70 @@ export class ChatToolConfirmationCarouselPart extends Disposable { return; } - // Create the tool part once and reuse it across tab switches if (!item.toolPart) { item.toolPart = this.toolPartFactory(item.tool); - item.disposables.add(item.toolPart); + if (item.ownsToolPart) { + item.disposables.add(item.toolPart); + } } this.contentContainer.appendChild(item.toolPart.domNode); } - /** - * Build a short tab label from the tool's invocation message and context. - * Falls back to the tool display name when no better label is available. - */ - private getTabLabel(tool: IChatToolInvocation): string { - const toolData = this.toolsService.getTool(tool.toolId); - const fallbackName = toolData?.displayName ?? tool.toolId; - - // For terminal tools, use the command as the label - if (tool.toolSpecificData?.kind === 'terminal') { - const terminalData = migrateLegacyTerminalToolSpecificData(tool.toolSpecificData); - const cmd = ( - terminalData.presentationOverrides?.commandLine ?? - terminalData.commandLine.forDisplay ?? - terminalData.commandLine.userEdited ?? - terminalData.commandLine.toolEdited ?? - terminalData.commandLine.original ?? - terminalData.confirmation?.commandLine - )?.trimStart() ?? ''; - return this.truncateLabel(cmd); - } - - // Use the invocation message as the primary label (e.g. "Reading /path/to/file") - const invocationText = this.toPlainText(tool.invocationMessage); - if (invocationText) { - return this.truncateLabel(invocationText); + allowAll(): void { + for (const item of [...this.items]) { + IChatToolInvocation.confirmWith(item.tool, { type: ToolConfirmKind.UserAction }); } + } - // Use the confirmation title (e.g. from the generic confirmation tool) - const confirmationMessages = IChatToolInvocation.getConfirmationMessages(tool); - const titleText = this.toPlainText(confirmationMessages?.title); - if (titleText) { - return this.truncateLabel(titleText); + private getToolTitle(item: ICarouselToolItem | undefined): string | undefined { + if (!item) { + return undefined; } - - // Fall back to originMessage context if available - const originText = this.toPlainText(tool.originMessage); - if (originText) { - return this.truncateLabel(originText); + const messages = IChatToolInvocation.getConfirmationMessages(item.tool); + if (!messages?.title) { + return undefined; } - - return fallbackName; + return this.truncateTitle(this.toPlainText(messages.title)); } - private truncateLabel(text: string): string { - // Normalize whitespace and truncate for stable tab layout + private truncateTitle(text: string): string { text = text.replace(/\s+/g, ' ').trim(); - const maxLength = 60; - if (text.length > maxLength) { - text = text.substring(0, maxLength) + '\u2026'; - } - return text; + const maxLength = 100; + return text.length > maxLength ? `${text.substring(0, maxLength)}\u2026` : text; } - /** - * Extract plain text from a string or IMarkdownString. - * For markdown links with empty display text like `[](uri)`, extracts the - * last path segment from the URI so labels read e.g. "Reading .vscode". - */ - private toPlainText(message: string | { value: string } | undefined | null): string { - if (!message) { - return ''; - } - if (typeof message === 'string') { - return message; + private toPlainText(message: string | IMarkdownString): string { + const markdown = typeof message === 'string' ? message : message.value; + return markdown + .replace(/\[([^\]]*)\]\(([^)]+)\)/g, (_match, text, url) => text || this.basename(url)) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/[\\*_#>]/g, ''); + } + + private basename(url: string): string { + try { + const path = decodeURIComponent(url.split('?')[0].split('#')[0]); + const segments = path.split('/').filter(Boolean); + return segments.at(-1) ?? url; + } catch { + return url; } - // Replace markdown links: [text](url) → text, or basename of url when text is empty - const resolved = message.value.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (_match, text, url) => { - if (text) { - return text; - } - // Extract last path segment as a readable name - try { - const path = decodeURIComponent(url.split('?')[0].split('#')[0]); - const segments = path.split('/').filter(Boolean); - return segments[segments.length - 1] ?? url; - } catch { - return url; - } - }); - return resolved; } - allowAll(): void { - for (const item of [...this.items]) { - IChatToolInvocation.confirmWith(item.tool, { type: ToolConfirmKind.UserAction }); + private scrollToActiveSubagent(): void { + const item = this.items[this.activeIndex]; + if (item?.subAgentInvocationId) { + item.scrollToSubagent?.(item.subAgentInvocationId); } } - skipAll(): void { - for (const item of [...this.items]) { - IChatToolInvocation.confirmWith(item.tool, { type: ToolConfirmKind.Skipped }); + activateFirstToolForSubagent(subAgentInvocationId: string): void { + const index = this.items.findIndex(i => i.subAgentInvocationId === subAgentInvocationId); + if (index >= 0) { + this.setActiveIndex(index); } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c5837f4208ba4..fcfb53a9bffcf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1725,6 +1725,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this._currentLayoutWidth.get(), this._announcedToolProgressKeys, ); + // Enable carousel mode before appendToolInvocation creates an inline part. + this.maybeRouteSubagentToolToCarousel(toolInvocation, subagentPart, context, codeBlockStartIndex); + // Don't append the parent subagent tool itself - its description is already shown in the title // Only append child tools (those with subAgentInvocationId) if (!isParentSubagentTool(toolInvocation)) { subagentPart.appendToolInvocation(toolInvocation, codeBlockStartIndex); } + return subagentPart; } + /** Routes subagent confirmations to the input carousel and leaves a placeholder inline. */ + private maybeRouteSubagentToolToCarousel( + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + subagentPart: ChatSubagentContentPart, + context: IChatContentPartRenderContext, + codeBlockStartIndex: number, + ): void { + if (!this.configService.getValue(ChatConfiguration.ToolConfirmationCarousel)) { + return; + } + if (toolInvocation.kind !== 'toolInvocation' || !isResponseVM(context.element)) { + return; + } + if (isParentSubagentTool(toolInvocation) || toolInvocation.presentation === 'hidden' || toolInvocation.source.type === 'mcp') { + return; + } + if (!!this.viewModel?.editing) { + return; + } + + const widget = this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource); + if (!widget) { + return; + } + + const subAgentInvocationId = subagentPart.subAgentInvocationId; + const agentName = subagentPart.getAgentLabel(); + + const scrollToSubagent = (targetSubAgentId: string) => { + const currentTemplateData = this.getTemplateDataForRequestId(context.element.id); + const currentSubagentPart = this.getSubagentPart(currentTemplateData?.renderedParts, targetSubAgentId) ?? subagentPart; + currentSubagentPart.domNode.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + + const navigateToCarousel = (targetSubAgentId: string) => { + widget.inputPart.activateCarouselForSubagent(targetSubAgentId); + }; + + const factory = (tool: IChatToolInvocation) => this.instantiationService.createInstance( + ChatToolInvocationPart, tool, context, + this.chatContentMarkdownRenderer, this._contentReferencesListPool, + this._toolEditorPool, () => this._currentLayoutWidth.get(), + this._announcedToolProgressKeys, + codeBlockStartIndex + ); + + const addToolToCarousel = (tool: IChatToolInvocation) => { + widget.inputPart.addToolToConfirmationCarousel(tool, factory, subAgentInvocationId, agentName, scrollToSubagent); + }; + const shouldUseCarouselForTool = (tool: IChatToolInvocation, state: IChatToolInvocation.State) => + this.configService.getValue(ChatConfiguration.ToolConfirmationCarousel) && + !this.viewModel?.editing && + tool.presentation !== 'hidden' && + tool.source.type !== 'mcp' && + state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && + !!state.confirmationMessages?.title; + + subagentPart.enableCarouselMode(navigateToCarousel, addToolToCarousel, shouldUseCarouselForTool); + + const toolState = toolInvocation.state.get(); + if (toolState.type === IChatToolInvocation.StateKind.WaitingForConfirmation && + toolState.confirmationMessages?.title) { + widget.inputPart.addToolToConfirmationCarousel(toolInvocation, factory, subAgentInvocationId, agentName, scrollToSubagent); + } + } + private finalizeCurrentThinkingPart(context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): void { const lastThinking = this.getLastThinkingPart(templateData.renderedParts); if (!lastThinking) { @@ -2000,16 +2073,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer - other.kind === 'toolInvocation' && widget.inputPart.hasToolInConfirmationCarousel(toolInvocation.toolCallId) - ); - } - } - const codeBlockStartIndex = context.codeBlockStartIndex; // Factory that creates the tool invocation part with all necessary setup @@ -2062,84 +2125,99 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.ToolConfirmationCarousel) && - toolInvocation.kind === 'toolInvocation' && isResponseVM(context.element) && toolInvocation.presentation !== 'hidden') { - - const isEditing = !!this.viewModel?.editing; - const isEligibleForCarousel = (tool: IChatToolInvocation): boolean => { - const toolState = tool.state.get(); - return tool.presentation !== 'hidden' && - toolState.type === IChatToolInvocation.StateKind.WaitingForConfirmation && - !!toolState.confirmationMessages?.title; - }; - if (!isEditing && isEligibleForCarousel(toolInvocation)) { - const widget = this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource); - if (widget) { - const otherWaitingTools = context.element.response.value.filter((part): part is IChatToolInvocation => - part.kind === 'toolInvocation' && - part.toolCallId !== toolInvocation.toolCallId && - isEligibleForCarousel(part) - ); - const hasCarouselOrMultipleWaiting = widget.inputPart.hasActiveToolConfirmationCarousel || otherWaitingTools.length > 0; - - if (hasCarouselOrMultipleWaiting) { - const factory = (tool: IChatToolInvocation) => this.instantiationService.createInstance( - ChatToolInvocationPart, tool, context, - this.chatContentMarkdownRenderer, this._contentReferencesListPool, - this._toolEditorPool, () => this._currentLayoutWidth.get(), - this._announcedToolProgressKeys, - codeBlockStartIndex - ); - - // When creating a new carousel, also absorb any other waiting tools - // that were already rendered inline - if (!widget.inputPart.hasActiveToolConfirmationCarousel) { - for (const otherTool of otherWaitingTools) { - widget.inputPart.addToolToConfirmationCarousel(otherTool, factory); - - // Hide the inline-rendered part and replace with a no-content sentinel - const renderedParts = templateData.renderedParts; - if (renderedParts) { - for (let i = 0; i < renderedParts.length; i++) { - const rp = renderedParts[i]; - if (rp instanceof ChatToolInvocationPart && rp.toolCallId === otherTool.toolCallId) { - rp.domNode?.remove(); - rp.dispose(); - renderedParts[i] = this.renderNoContent((other) => - other.kind === 'toolInvocation' && widget.inputPart.hasToolInConfirmationCarousel(otherTool.toolCallId) - ); - break; - } - } - } - } - } + toolInvocation.kind === 'toolInvocation' && isResponseVM(context.element) && + toolInvocation.source.type !== 'mcp' && !this.viewModel?.editing) { + const widget = this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource); + if (widget) { + const factory = (tool: IChatToolInvocation) => this.instantiationService.createInstance( + ChatToolInvocationPart, tool, context, + this.chatContentMarkdownRenderer, this._contentReferencesListPool, + this._toolEditorPool, () => this._currentLayoutWidth.get(), + this._announcedToolProgressKeys, + codeBlockStartIndex + ); + const routePartToCarousel = (): boolean => { + inlinePartAnchor ??= this.createInlinePartAnchor(part); + if (!inlinePartAnchor) { + return false; + } - widget.inputPart.renderToolConfirmationCarousel(toolInvocation, factory); - return this.renderNoContent((other) => { - if (other.kind !== 'toolInvocation') { - return false; - } - return widget.inputPart.hasToolInConfirmationCarousel(toolInvocation.toolCallId); - }); + dom.show(part.domNode); + widget.inputPart.addToolToConfirmationCarousel(toolInvocation, factory, undefined, undefined, undefined, part); + if (part.domNode.parentElement === inlinePartAnchor.parentElement) { + part.domNode.remove(); } - } + return true; + }; + let hasScheduledCarouselRoute = false; + const scheduleRoutePartToCarousel = () => { + if (hasScheduledCarouselRoute) { + return; + } + + hasScheduledCarouselRoute = true; + part.addDisposable(dom.scheduleAtNextAnimationFrame(dom.getWindow(part.domNode), () => { + hasScheduledCarouselRoute = false; + const state = toolInvocation.state.get(); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && state.confirmationMessages?.title && + toolInvocation.presentation !== 'hidden' && + toolInvocation.source.type !== 'mcp' && + !this.viewModel?.editing) { + routePartToCarousel(); + } + })); + }; + part.addDisposable(autorun(reader => { + const state = toolInvocation.state.read(reader); + const isCarouselConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && + !!state.confirmationMessages?.title && + toolInvocation.presentation !== 'hidden' && + toolInvocation.source.type !== 'mcp' && + !this.viewModel?.editing; + + if (isCarouselConfirmation) { + if (!routePartToCarousel()) { + scheduleRoutePartToCarousel(); + } + } else if (IChatToolInvocation.isEffectivelyHidden(toolInvocation, reader)) { + this.restoreInlinePart(part, inlinePartAnchor); + dom.hide(part.domNode); + } else { + this.restoreInlinePart(part, inlinePartAnchor); + dom.show(part.domNode); + } + })); } } - // For cases not handled above (no thinking part, no subagent, etc.), create the part now - const { part } = createToolPart(); - return part; } + private createInlinePartAnchor(part: ChatToolInvocationPart): HTMLElement | undefined { + const parent = part.domNode?.parentElement; + if (!parent) { + return undefined; + } + + const anchor = dom.$('.chat-tool-carousel-inline-anchor'); + anchor.style.display = 'none'; + parent.insertBefore(anchor, part.domNode); + part.addDisposable(toDisposable(() => anchor.remove())); + return anchor; + } + + private restoreInlinePart(part: ChatToolInvocationPart, anchor: HTMLElement | undefined): void { + if (anchor?.parentElement && part.domNode?.parentElement !== anchor.parentElement) { + anchor.parentElement.insertBefore(part.domNode, anchor.nextSibling); + } + } + // watch for confirmation part transition when tool invocation is streaming private setupConfirmationTransitionWatcher( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, @@ -2153,11 +2231,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const moveConfirmationWidgetOutOfThinking = (): ChatToolInvocationPart => { const createdPart = getCreatedPart(); - // move the created part out of thinking and into the main template toolInvocation.isAttachedToThinking = false; + let part: ChatToolInvocationPart; if (createdPart?.domNode) { + part = createdPart; const wrapper = createdPart.domNode.parentElement; if (wrapper?.classList.contains('chat-thinking-tool-wrapper')) { wrapper.remove(); @@ -2167,7 +2246,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer + type === IChatToolInvocation.StateKind.Streaming || type === IChatToolInvocation.StateKind.Executing; + + const tryRouteConfirmationToCarousel = (): boolean => { + if (!this.configService.getValue(ChatConfiguration.ToolConfirmationCarousel) || + !isResponseVM(context.element) || + this.viewModel?.editing || + toolInvocation.presentation === 'hidden' || + toolInvocation.source.type === 'mcp') { + return false; + } + + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { + return false; + } + + const widget = this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource); + if (!widget) { + return false; + } + + const part = moveConfirmationWidgetOutOfThinking(); + const factory = (tool: IChatToolInvocation) => this.instantiationService.createInstance( + ChatToolInvocationPart, tool, context, + this.chatContentMarkdownRenderer, this._contentReferencesListPool, + this._toolEditorPool, () => this._currentLayoutWidth.get(), + this._announcedToolProgressKeys, + context.codeBlockStartIndex + ); + + part.addDisposable(autorun(reader => { + const currentState = toolInvocation.state.read(reader); + if (currentState.type === IChatToolInvocation.StateKind.WaitingForConfirmation && currentState.confirmationMessages?.title) { + widget.inputPart.addToolToConfirmationCarousel(toolInvocation, factory); + dom.hide(part.domNode); + } else if (IChatToolInvocation.isEffectivelyHidden(toolInvocation, reader)) { + dom.hide(part.domNode); + } else { + dom.show(part.domNode); + } + })); + + return true; }; const currentState = toolInvocation.state.get(); - if (currentState.type === IChatToolInvocation.StateKind.WaitingForConfirmation || currentState.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { - removeConfirmationWidget(); + if (currentState.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { + if (!tryRouteConfirmationToCarousel()) { + moveConfirmationWidgetOutOfThinking(); + } + return; + } + if (currentState.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { + moveConfirmationWidgetOutOfThinking(); return; } - - const isWorkingState = (type: IChatToolInvocation.StateKind) => - type === IChatToolInvocation.StateKind.Streaming || type === IChatToolInvocation.StateKind.Executing; if (!isWorkingState(currentState.type)) { return; @@ -2201,7 +2332,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer Date: Thu, 9 Apr 2026 10:40:56 +0200 Subject: [PATCH 09/15] bring back debugging log (#308706) --- .../browser/copilotChatSessionsProvider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index aee5381dbeeb9..8251fc78a792c 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1406,7 +1406,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } // Send request - this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id}`); + this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, { + userSelectedModelId: sendOptions.userSelectedModelId, + }); const result = await this.chatService.sendRequest(session.resource, query, sendOptions); if (result.kind === 'rejected') { throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); From 0271b4d2fe910341fa33a2a4748835168c2a7f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Thu, 9 Apr 2026 10:45:20 +0200 Subject: [PATCH 10/15] =?UTF-8?q?Refactor=20applyPackageJsonPatch=20to=20r?= =?UTF-8?q?emove=20isPreRelease=20parameter=20and=20d=E2=80=A6=20(#308708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor applyPackageJsonPatch to remove isPreRelease parameter and derive it from VSCODE_QUALITY environment variable; add getDateBasedPatch function for versioning Co-authored-by: Copilot --- extensions/copilot/.esbuild.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/.esbuild.ts b/extensions/copilot/.esbuild.ts index 5daec3f855d43..0522b0266b2f3 100644 --- a/extensions/copilot/.esbuild.ts +++ b/extensions/copilot/.esbuild.ts @@ -12,7 +12,6 @@ import * as path from 'path'; const REPO_ROOT = import.meta.dirname; const isWatch = process.argv.includes('--watch'); const isDev = process.argv.includes('--dev'); -const isPreRelease = process.argv.includes('--prerelease'); const generateSourceMaps = process.argv.includes('--sourcemaps'); const sourceMapOutDir = './dist-sourcemaps'; @@ -323,8 +322,8 @@ async function moveSourceMapsToSeparateDir(): Promise { } async function main() { - if (!isDev) { - applyPackageJsonPatch(isPreRelease); + if (!isDev) { // TODO@joaomoreno + applyPackageJsonPatch(); } await typeScriptServerPluginPackageJsonInstall(); @@ -426,15 +425,41 @@ async function main() { } } -function applyPackageJsonPatch(isPreRelease: boolean) { +function applyPackageJsonPatch() { + const quality = process.env['VSCODE_QUALITY']; + + if (!quality) { + throw new Error('VSCODE_QUALITY environment variable is not set. This should be set by the build pipeline to ensure correct versioning and pre-release status in package.json.'); + } + const packagejsonPath = path.join(import.meta.dirname, './package.json'); const json = JSON.parse(fs.readFileSync(packagejsonPath).toString()); + const isPreRelease = quality !== 'stable'; + + const rootPackageJsonPath = path.join(import.meta.dirname, '../../package.json'); + const rootPackageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath).toString()); + const vscodeVersion = rootPackageJson.version; + + const [, vscodeMinor, vscodePatch] = vscodeVersion.split('.'); + const newMajor = 0; // Keep major version at 0 + const newMinor = parseInt(vscodeMinor, 10) - (115 - 43); // VS Code 1.115.x -> Copilot Chat 0.43.x + const newPatch = isPreRelease ? getDateBasedPatch() : vscodePatch; // For stable releases, keep the same patch number as VS Code + const newProps = { buildType: 'prod', isPreRelease, + version: `${newMajor}.${newMinor}.${newPatch}` }; fs.writeFileSync(packagejsonPath, JSON.stringify({ ...json, ...newProps }, null, '\t')); } +function getDateBasedPatch(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}${month}${day}01`; // TODO@joaomoreno fix this asap +} + main(); From 70888c4092586b81e97840d8af13d9c1274e1f0a Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Thu, 9 Apr 2026 14:22:42 +0500 Subject: [PATCH 11/15] nes: fix: ensure request log tree entries always have icons (#308713) --- .../inlineEdits/node/nextEditProvider.ts | 21 ++++++++++++------- .../log/vscode-node/requestLogTree.ts | 10 +++------ .../common/inlineEditLogContext.ts | 12 +++++++---- .../inlineEdits/common/utils/utils.ts | 10 +++++++++ 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index 733e831341918..138401e4cff9b 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -420,6 +420,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider { + this._addLiveLogContextEntry(logContext, label); try { await this._runSpeculativeProviderCall(nextEditRequest, projectedDocuments, curDocId, req, logger); + } catch (e) { + logContext.setError(e); } finally { - this._addLogContextEntry(logContext, label); + logContext.markCompleted(); } }); @@ -1324,6 +1329,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider logContext.getIcon(), startTimeMs: logContext.time, - markdownContent: logContext.toLogDocument(), + markdownContent: () => logContext.toLogDocument(), + onDidChange: logContext.onDidChange, + isVisible: () => logContext.includeInLogTree, }); } diff --git a/extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts b/extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts index cf357653855aa..0afcca3455824 100644 --- a/extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts +++ b/extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts @@ -619,16 +619,12 @@ class ChatRequestProvider extends Disposable implements vscode.TreeDataProvider< // whose debugName matches the token label), associate it directly with the // parent ChatPromptItem — don't add it as a child. The entry stays in the // request logger for virtual document serving; only tree nesting changes. - // Only wire the main entry if it is visible — for live NES/Ghost entries, - // isVisible() can be false (e.g. skipped/cancelled); wiring a hidden entry - // would make it visible again via the parent's icon and click command. + // Always wire the main entry so the parent node is clickable and shows the + // current icon (e.g. loading, lightbulb, skipped, circleSlash, etc.). if (currReq.kind === LoggedInfoKind.Request && currReq.entry.type === LoggedRequestKind.MarkdownContentRequest && currReq.entry.debugName === currReq.token.label) { - const isHidden = currReq.entry.isVisible && !currReq.entry.isVisible(); - if (!isHidden) { - prompt.setMainEntry(currReq); - } + prompt.setMainEntry(currReq); continue; } diff --git a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts index 820ec52930f1e..f12d1387ad6d5 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts @@ -357,8 +357,12 @@ export class InlineEditRequestLogContext { private _icon: Icon.t | undefined; - getIcon(): ThemeIcon | undefined { - return this._icon?.themeIcon; + private _resolveIcon(): Icon.t { + return this._icon ?? (this._isCompleted ? Icon.check : Icon.loading); + } + + getIcon(): ThemeIcon { + return this._resolveIcon().themeIcon; } public setIsSkipped() { @@ -460,8 +464,8 @@ export class InlineEditRequestLogContext { } getMarkdownTitle(): string { - const icon: string = this._icon ? `${this._icon.svg} ` : ''; - return (icon) + this.getDebugName(); + const icon = this._resolveIcon(); + return `${icon.svg} ` + this.getDebugName(); } protected _recentEdit: HistoryContext | undefined = undefined; diff --git a/extensions/copilot/src/platform/inlineEdits/common/utils/utils.ts b/extensions/copilot/src/platform/inlineEdits/common/utils/utils.ts index 1e9340186f300..80a7fde061371 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/utils/utils.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/utils/utils.ts @@ -53,6 +53,16 @@ export namespace Icon { themeIcon: ThemeIcon.fromId('database'), svg: ``, }; + + export const loading: t = { + themeIcon: ThemeIcon.fromId('loading~spin'), + svg: ``, + }; + + export const check: t = { + themeIcon: ThemeIcon.fromId('check'), + svg: ``, + }; } export function shortenOpportunityId(opportunityId: string): string { From 8a8c65624348e7598010d9d3b84d63154fc1007f Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 9 Apr 2026 09:00:27 +0200 Subject: [PATCH 12/15] Sanitize network errors --- .../log/vscode-node/loggingActions.ts | 4 +- .../test/sanitizeNetworkError.spec.ts | 125 ++++++++++++++++++ .../src/platform/log/common/logService.ts | 20 +++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 extensions/copilot/src/extension/log/vscode-node/test/sanitizeNetworkError.spec.ts diff --git a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts index d0fa0761a6981..f78b761d4c72e 100644 --- a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +++ b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts @@ -17,7 +17,7 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio import { ICAPIClientService } from '../../../platform/endpoint/common/capiClient'; import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/envService'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; -import { collectErrorMessages, collectSingleLineErrorMessage, ILogService } from '../../../platform/log/common/logService'; +import { collectErrorMessages, collectSingleLineErrorMessage, ILogService, sanitizeNetworkErrorForTelemetry } from '../../../platform/log/common/logService'; import { outputChannel } from '../../../platform/log/vscode/outputChannelLogTarget'; import { FetchEvent, IFetcherService } from '../../../platform/networking/common/fetcherService'; import { IFetcher, userAgentLibraryHeader } from '../../../platform/networking/common/networking'; @@ -520,7 +520,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { probeResults[key] = `Status: ${response.status}`; logService.debug(`Fetcher telemetry probe: ${library} ${probeResults[key]} (${Date.now() - requestStartTime}ms)`); } catch (e) { - probeResults[key] = `Error: ${collectSingleLineErrorMessage(e, true)}`; + probeResults[key] = `Error: ${sanitizeNetworkErrorForTelemetry(collectSingleLineErrorMessage(e, true))}`; logService.debug(`Fetcher telemetry probe: ${library} ${probeResults[key]} (${Date.now() - requestStartTime}ms)`); } } diff --git a/extensions/copilot/src/extension/log/vscode-node/test/sanitizeNetworkError.spec.ts b/extensions/copilot/src/extension/log/vscode-node/test/sanitizeNetworkError.spec.ts new file mode 100644 index 0000000000000..a4a435661db32 --- /dev/null +++ b/extensions/copilot/src/extension/log/vscode-node/test/sanitizeNetworkError.spec.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, test } from 'vitest'; +import { sanitizeNetworkErrorForTelemetry } from '../../../../platform/log/common/logService'; + +describe('sanitizeNetworkErrorForTelemetry', () => { + test('strips credentials from PROXY strings', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'Failed to establish a socket connection to proxies: PROXY testuser:testpass@proxy.fictional.example.com:8080' + )).toBe( + 'Failed to establish a socket connection to proxies: PROXY @:8080' + ); + }); + + test('strips credentials with special characters', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'PROXY jane.fictional%40corp.example.com:fictional123@proxy.fictional.example.com:8080' + )).toBe( + 'PROXY @:8080' + ); + }); + + test('strips credentials from HTTPS proxy', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'HTTPS fictional-user:fictional-pass@proxy.fictional.example.com:8443' + )).toBe( + 'HTTPS @:8443' + ); + }); + + test('strips credentials from URLs', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'https://fictional-user:fictional-pass@proxy.fictional.example.com:8080/path' + )).toBe( + 'https://@:8080/path' + ); + }); + + test('replaces IPv4 addresses', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'connect ETIMEDOUT 10.20.30.40:443' + )).toBe( + 'connect ETIMEDOUT :443' + ); + }); + + test('replaces multiple IPv4 addresses', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'Connect Timeout Error (attempted addresses: 192.168.1.100:443, 2001:db8::1a2b:3c4d:443, timeout: 10000ms)' + )).toBe( + 'Connect Timeout Error (attempted addresses: :443, , timeout: 10000ms)' + ); + }); + + test('replaces FQDNs', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'getaddrinfo ENOTFOUND proxy.fictional.example.com' + )).toBe( + 'getaddrinfo ENOTFOUND ' + ); + }); + + test('replaces FQDN with port', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'Connect Timeout Error (attempted address: telemetry.fictional.example.com:443, timeout: 10000ms)' + )).toBe( + 'Connect Timeout Error (attempted address: :443, timeout: 10000ms)' + ); + }); + + test('preserves simple status messages', () => { + expect(sanitizeNetworkErrorForTelemetry('Status: 200')).toBe('Status: 200'); + }); + + test('replaces loopback addresses', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'ECONNREFUSED: connect ECONNREFUSED 0.0.0.0:443, connect ECONNREFUSED :::443' + )).toBe( + 'ECONNREFUSED: connect ECONNREFUSED :443, connect ECONNREFUSED ' + ); + }); + + test('replaces IPv6 addresses', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'connect ENETUNREACH 2001:db8::1a2b:3c4d:443 - Local (:::0)' + )).toBe( + 'connect ENETUNREACH - Local ()' + ); + }); + + test('does not match non-IPv6 double colons', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'net::ERR_SOCKET_NOT_CONNECTED' + )).toBe( + 'net::ERR_SOCKET_NOT_CONNECTED' + ); + }); + + test('handles combined proxy credentials and hostnames', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'Failed to establish a socket connection to proxies: PROXY fictional-admin:p%40ssw0rd@proxy.fictional.example.com:80/' + )).toBe( + 'Failed to establish a socket connection to proxies: PROXY @:80/' + ); + }); + + test('replaces host in PROXY without credentials', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'Failed to establish a socket connection to proxies: PROXY proxy.fictional.example.com:8080' + )).toBe( + 'Failed to establish a socket connection to proxies: PROXY :8080' + ); + }); + + test('handles multiple proxies with credentials', () => { + expect(sanitizeNetworkErrorForTelemetry( + 'Failed to establish a socket connection to proxies: PROXY testuser:testpass@proxy1.fictional.example.com:8080; PROXY proxy2.fictional.example.com:8080; DIRECT' + )).toBe( + 'Failed to establish a socket connection to proxies: PROXY @:8080; PROXY :8080; DIRECT' + ); + }); +}); diff --git a/extensions/copilot/src/platform/log/common/logService.ts b/extensions/copilot/src/platform/log/common/logService.ts index 7b9b5ee38acdf..9dd72166a59a9 100644 --- a/extensions/copilot/src/platform/log/common/logService.ts +++ b/extensions/copilot/src/platform/log/common/logService.ts @@ -403,6 +403,26 @@ export function collectSingleLineErrorMessage(e: any, includeDetails = false): s return collect(e); } +/** + * Sanitizes a network error message for telemetry by replacing hostnames, + * IP addresses, and credentials with placeholders. + */ +export function sanitizeNetworkErrorForTelemetry(message: string): string { + // Strip credentials and host from proxy result strings (e.g., "PROXY user:pass@host" → "PROXY @") + message = message.replace(/(\b(?:PROXY|HTTPS?|SOCKS[45]?)\s+)[^\s@]+@([^\s:\/]+)/gi, '$1@'); + // Strip host from proxy result strings without credentials (e.g., "PROXY host:8080" → "PROXY :8080") + message = message.replace(/(\b(?:PROXY|HTTPS?|SOCKS[45]?)\s+)([a-zA-Z0-9][-a-zA-Z0-9.]*)/gi, '$1'); + // Strip credentials and host from URLs (e.g., "://user:pass@host" → "://@") + message = message.replace(/(\/\/)[^\s/@]+@([^\s:\/]+)/g, '$1@'); + // Replace IPv4 addresses, preserving the port if present + message = message.replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, ''); + // Replace IPv6 addresses (compressed form with ::, e.g., "2001:db8::1" or "::1") + message = message.replace(/(?'); + // Replace FQDNs (at least one dot, TLD of 2+ alpha chars), preserving the port if present + message = message.replace(/\b([a-zA-Z0-9][-a-zA-Z0-9]*\.)+[a-zA-Z]{2,}\b/g, ''); + return message; +} + /** * Chromium error details attached by Electron to fetch errors. * Electron's network service process runs separately; when it crashes, From a2188f2bcc6c88a782702e99963722f87250414b Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 9 Apr 2026 10:04:17 +0000 Subject: [PATCH 13/15] Add support for CLS to setConfigs mid-runtime (#308723) Co-authored-by: Andrea Mah --- .../copilot/src/lib/node/chatLibMain.ts | 51 ++++++++++++++++++- .../defaultsOnlyConfigurationService.ts | 2 +- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/lib/node/chatLibMain.ts b/extensions/copilot/src/lib/node/chatLibMain.ts index a157b113fcedb..1da2e0819dbfd 100644 --- a/extensions/copilot/src/lib/node/chatLibMain.ts +++ b/extensions/copilot/src/lib/node/chatLibMain.ts @@ -67,7 +67,7 @@ import { IChatQuotaService } from '../../platform/chat/common/chatQuotaService'; import { ChatQuotaService } from '../../platform/chat/common/chatQuotaServiceImpl'; import { IConversationOptions } from '../../platform/chat/common/conversationOptions'; import { IInteractionService, InteractionService } from '../../platform/chat/common/interactionService'; -import { BaseConfig, Config, ConfigKey, ExperimentBasedConfig, ExperimentBasedConfigType, IConfigurationService } from '../../platform/configuration/common/configurationService'; +import { BaseConfig, Config, ConfigKey, CopilotConfigPrefix, ExperimentBasedConfig, ExperimentBasedConfigType, globalConfigRegistry, IConfigurationService } from '../../platform/configuration/common/configurationService'; import { DefaultsOnlyConfigurationService } from '../../platform/configuration/common/defaultsOnlyConfigurationService'; import { IDiffService } from '../../platform/diff/common/diffService'; import { DiffServiceImpl } from '../../platform/diff/node/diffServiceImpl'; @@ -208,6 +208,7 @@ export interface INESProvider { handleRejection(suggestion: T): void; handleIgnored(suggestion: T, supersededByRequestUuid: T | undefined): void; updateTreatmentVariables(variables: Record): void; + setConfigs(overrides: Map): Promise; dispose(): void; } @@ -348,6 +349,15 @@ class NESProvider extends Disposable implements INESProvider { } } + async setConfigs(overrides: Map) { + for (const [key, value] of overrides) { + const config = globalConfigRegistry.configs.get(`${CopilotConfigPrefix}.${key}`); + if (config) { + await this._configurationService.setConfig(config, value); + } + } + } + } function setupServices(options: INESProviderOptions) { @@ -402,8 +412,30 @@ function setupServices(options: INESProviderOptions) { } class OverridableConfigurationService extends DefaultsOnlyConfigurationService { - constructor(private readonly _overrides: Map) { + private _overrides: Map; + + constructor(overrides: Map) { super(); + this._overrides = overrides; + } + + override async setConfig(key: BaseConfig, value: T): Promise { + const existing = this._overrides.get(key.id); + if (existing === value) { + return; + } + if (value === undefined) { + this._overrides.delete(key.id); + } else { + this._overrides.set(key.id, value); + } + const fullyQualifiedKey = key.fullyQualifiedId; + this._onDidChangeConfiguration.fire({ + affectsConfiguration: (section) => { + return fullyQualifiedKey === section || fullyQualifiedKey.startsWith(section + '.') || section.startsWith(fullyQualifiedKey + '.'); + } + }); + return; } override getConfig(key: Config): T { @@ -727,6 +759,7 @@ export type IGetInlineCompletionsOptions = Exclude, export interface IInlineCompletionsProvider { updateTreatmentVariables(variables: Record): void; + setConfigs(overrides: Map): Promise; getInlineCompletions(textDocument: ITextDocument, position: Position, token?: CancellationToken, options?: IGetInlineCompletionsOptions): Promise; inlineCompletionShown(completionId: string): Promise; dispose(): void; @@ -746,6 +779,8 @@ class InlineCompletionsProvider extends Disposable implements IInlineCompletions @IExperimentationService private readonly _expService: IExperimentationService, @ICompletionsSpeculativeRequestCache private readonly _speculativeRequestCache: ICompletionsSpeculativeRequestCache, @ILogService private readonly _logService: ILogService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ICompletionsConfigProvider private readonly _completionsConfigProvider: ICompletionsConfigProvider, ) { super(); this._register(_insta); @@ -758,6 +793,18 @@ class InlineCompletionsProvider extends Disposable implements IInlineCompletions } } + async setConfigs(overrides: Map) { + for (const [key, value] of overrides) { + const config = globalConfigRegistry.configs.get(`${CopilotConfigPrefix}.${key}`); + if (config) { + await this._configurationService.setConfig(config, value); + } + } + if (this._completionsConfigProvider instanceof InMemoryConfigProvider) { + this._completionsConfigProvider.setCopilotSettings(Object.fromEntries(overrides)); + } + } + async getInlineCompletions(textDocument: ITextDocument, position: Position, token?: CancellationToken, options?: IGetInlineCompletionsOptions): Promise { const telemetryBuilder = new LlmNESTelemetryBuilder(undefined, undefined, undefined, 'ghostText', undefined); return await this.ghostText.getInlineCompletions(textDocument, position, token ?? CancellationToken.None, options, new GhostTextLogContext(textDocument.uri, textDocument.version, undefined), telemetryBuilder, this._logService); diff --git a/extensions/copilot/src/platform/configuration/common/defaultsOnlyConfigurationService.ts b/extensions/copilot/src/platform/configuration/common/defaultsOnlyConfigurationService.ts index d9c95ee433f3f..489c9e5d7383e 100644 --- a/extensions/copilot/src/platform/configuration/common/defaultsOnlyConfigurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/defaultsOnlyConfigurationService.ts @@ -21,7 +21,7 @@ export class DefaultsOnlyConfigurationService extends AbstractConfigurationServi }; } - override setConfig(): Promise { + override setConfig(key: BaseConfig, value: T): Promise { return Promise.resolve(); } From dd50b343722b30d74b13573eb83d58bfc424e118 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Wed, 8 Apr 2026 15:51:06 +0200 Subject: [PATCH 14/15] nes: cleanup: extract edit intent parsing --- .../src/extension/xtab/node/editIntent.ts | 205 ++++++++++++++++++ .../src/extension/xtab/node/xtabProvider.ts | 198 +---------------- .../xtab/test/node/editIntent.spec.ts | 2 +- 3 files changed, 207 insertions(+), 198 deletions(-) create mode 100644 extensions/copilot/src/extension/xtab/node/editIntent.ts diff --git a/extensions/copilot/src/extension/xtab/node/editIntent.ts b/extensions/copilot/src/extension/xtab/node/editIntent.ts new file mode 100644 index 0000000000000..ffa3b80f7da7b --- /dev/null +++ b/extensions/copilot/src/extension/xtab/node/editIntent.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as xtabPromptOptions from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { ILogger } from '../../../platform/log/common/logService'; + +export interface ParseEditIntentResult { + editIntent: xtabPromptOptions.EditIntent; + remainingLinesStream: AsyncIterable; + parseError?: string; +} +/** + * Mode for parsing edit intent from the model response. + */ + +export enum EditIntentParseMode { + /** Parse using XML-style tags: <|edit_intent|>value<|/edit_intent|> */ + Tags = 'tags', + /** Parse using short names on the first line: N|L|M|H */ + ShortName = 'shortName' +} +/** + * Parses the edit_intent from the first line of the response stream. + * The edit_intent MUST be on the first line, otherwise it's treated as not provided. + * Returns the parsed EditIntent and a new stream with the remaining content. + * + * Supports two modes: + * - Tags (default): <|edit_intent|>low|medium|high|no_edit<|/edit_intent|> + * - ShortName: N|L|M|H on the first line + * + * @param linesStream The stream of lines from the model response + * @param tracer Logger for tracing + * @param mode The parse mode (Tags or ShortName), defaults to Tags + */ + +export async function parseEditIntentFromStream( + linesStream: AsyncIterable, + tracer: ILogger, + mode: EditIntentParseMode = EditIntentParseMode.Tags +): Promise { + if (mode === EditIntentParseMode.ShortName) { + return parseEditIntentFromStreamShortName(linesStream, tracer); + } + + return parseEditIntentFromStreamTags(linesStream, tracer); +} +/** + * Parses the edit_intent using short name format (N|L|M|H on first line). + */ + +async function parseEditIntentFromStreamShortName( + linesStream: AsyncIterable, + tracer: ILogger +): Promise { + let editIntent: xtabPromptOptions.EditIntent = xtabPromptOptions.EditIntent.High; // Default to high (always show) if no short name found + let parseError: string | undefined; + + const linesIter = linesStream[Symbol.asyncIterator](); + const firstLineResult = await linesIter.next(); + + if (firstLineResult.done) { + // Empty stream + parseError = 'emptyResponse'; + tracer.warn(`Empty response stream, no edit_intent short name found`); + const remainingLinesStream: AsyncIterable = (async function* () { })(); + return { editIntent, remainingLinesStream, parseError }; + } + + const firstLine = firstLineResult.value.trim(); + + // Check if the first line is a single character short name + const parsedIntent = xtabPromptOptions.EditIntent.fromShortName(firstLine); + + if (parsedIntent !== undefined) { + editIntent = parsedIntent; + tracer.trace(`Parsed edit_intent short name from first line: "${firstLine}" -> ${editIntent}`); + + // Create a new stream with the remaining lines (excluding the short name line) + const remainingLinesStream: AsyncIterable = (async function* () { + let next = await linesIter.next(); + while (!next.done) { + yield next.value; + next = await linesIter.next(); + } + })(); + + return { editIntent, remainingLinesStream, parseError }; + } + + // Short name not found or invalid + parseError = `unknownIntentValue:${firstLine}`; + + tracer.warn(`Edit intent parse error: ${parseError} (using Xtab275EditIntentShort prompting strategy). ` + + `Defaulting to High (always show). First line was: "${firstLine.substring(0, 100)}..."`); + + // Return the first line plus the rest of the stream + const remainingLinesStream: AsyncIterable = (async function* () { + yield firstLineResult.value; // Use original value, not trimmed + let next = await linesIter.next(); + while (!next.done) { + yield next.value; + next = await linesIter.next(); + } + })(); + + return { editIntent, remainingLinesStream, parseError }; +} +/** + * Parses the edit_intent tag from the first line of the response stream (original tag-based format). + */ + +async function parseEditIntentFromStreamTags( + linesStream: AsyncIterable, + tracer: ILogger +): Promise { + const EDIT_INTENT_START_TAG = '<|edit_intent|>'; + const EDIT_INTENT_END_TAG = '<|/edit_intent|>'; + + let editIntent: xtabPromptOptions.EditIntent = xtabPromptOptions.EditIntent.High; // Default to high (always show) if no tag found + let parseError: string | undefined; + + const linesIter = linesStream[Symbol.asyncIterator](); + const firstLineResult = await linesIter.next(); + + if (firstLineResult.done) { + // Empty stream + parseError = 'emptyResponse'; + tracer.warn(`Empty response stream, no edit_intent tag found`); + const remainingLinesStream: AsyncIterable = (async function* () { })(); + return { editIntent, remainingLinesStream, parseError }; + } + + const firstLine = firstLineResult.value; + + // Check if the first line contains the complete edit_intent tag + const startIdx = firstLine.indexOf(EDIT_INTENT_START_TAG); + const endIdx = firstLine.indexOf(EDIT_INTENT_END_TAG); + + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + // Found complete tag on first line + const intentValue = firstLine.substring( + startIdx + EDIT_INTENT_START_TAG.length, + endIdx + ).trim().toLowerCase(); + + // Check if it's a known intent value + const knownIntentValues = ['no_edit', 'low', 'medium', 'high']; + if (!knownIntentValues.includes(intentValue)) { + parseError = `unknownIntentValue:${intentValue}`; + tracer.warn(`Unknown edit_intent value: "${intentValue}", defaulting to High`); + } + + editIntent = xtabPromptOptions.EditIntent.fromString(intentValue); + tracer.trace(`Parsed edit_intent from first line: "${intentValue}" -> ${editIntent}`); + + // Calculate remaining content after the end tag on the first line + const afterEndTag = firstLine.substring(endIdx + EDIT_INTENT_END_TAG.length); + + // Create a new stream that first yields remaining content from first line, then continues + const remainingLinesStream: AsyncIterable = (async function* () { + // Only yield remaining content from first line if non-empty + if (afterEndTag.trim() !== '') { + yield afterEndTag; + } + // Continue with rest of the stream + let next = await linesIter.next(); + while (!next.done) { + yield next.value; + next = await linesIter.next(); + } + })(); + + return { editIntent, remainingLinesStream, parseError }; + } + + // Determine the parse error type + if (startIdx !== -1 && endIdx === -1) { + // Start tag found but no end tag - malformed (possibly split across lines) + parseError = 'malformedTag:startWithoutEnd'; + } else if (startIdx === -1 && endIdx !== -1) { + // End tag found but no start tag - malformed + parseError = 'malformedTag:endWithoutStart'; + } else { + // No tag found at all + parseError = 'noTagFound'; + } + + tracer.warn(`Edit intent parse error: ${parseError} (using Xtab275EditIntent prompting strategy). ` + + `Defaulting to High (always show). First line was: "${firstLine.substring(0, 100)}..."`); + + // Return the first line plus the rest of the stream + const remainingLinesStream: AsyncIterable = (async function* () { + yield firstLine; + let next = await linesIter.next(); + while (!next.done) { + yield next.value; + next = await linesIter.next(); + } + })(); + + return { editIntent, remainingLinesStream, parseError }; +} + diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index df6dfc987046e..5afe4784b5417 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -66,6 +66,7 @@ import { PromptTags, ResponseTags } from '../common/tags'; import { TerminalMonitor } from '../common/terminalOutput'; import { CurrentDocument } from '../common/xtabCurrentDocument'; import { getCurrentCursorLine, isModelCursorLineCompatible } from './cursorLineDivergence'; +import { EditIntentParseMode, parseEditIntentFromStream } from './editIntent'; import { XtabCustomDiffPatchResponseHandler } from './xtabCustomDiffPatchResponseHandler'; import { XtabEndpoint } from './xtabEndpoint'; import { CursorJumpPrediction, XtabNextCursorPredictor } from './xtabNextCursorPredictor'; @@ -1658,203 +1659,6 @@ export function getPredictionContents(doc: StatelessNextEditDocument, editWindow } } -export interface ParseEditIntentResult { - editIntent: xtabPromptOptions.EditIntent; - remainingLinesStream: AsyncIterable; - parseError?: string; -} - -/** - * Mode for parsing edit intent from the model response. - */ -export enum EditIntentParseMode { - /** Parse using XML-style tags: <|edit_intent|>value<|/edit_intent|> */ - Tags = 'tags', - /** Parse using short names on the first line: N|L|M|H */ - ShortName = 'shortName', -} - -/** - * Parses the edit_intent from the first line of the response stream. - * The edit_intent MUST be on the first line, otherwise it's treated as not provided. - * Returns the parsed EditIntent and a new stream with the remaining content. - * - * Supports two modes: - * - Tags (default): <|edit_intent|>low|medium|high|no_edit<|/edit_intent|> - * - ShortName: N|L|M|H on the first line - * - * @param linesStream The stream of lines from the model response - * @param tracer Logger for tracing - * @param mode The parse mode (Tags or ShortName), defaults to Tags - */ -export async function parseEditIntentFromStream( - linesStream: AsyncIterable, - tracer: ILogger, - mode: EditIntentParseMode = EditIntentParseMode.Tags, -): Promise { - if (mode === EditIntentParseMode.ShortName) { - return parseEditIntentFromStreamShortName(linesStream, tracer); - } - - return parseEditIntentFromStreamTags(linesStream, tracer); -} - -/** - * Parses the edit_intent using short name format (N|L|M|H on first line). - */ -async function parseEditIntentFromStreamShortName( - linesStream: AsyncIterable, - tracer: ILogger, -): Promise { - let editIntent: xtabPromptOptions.EditIntent = xtabPromptOptions.EditIntent.High; // Default to high (always show) if no short name found - let parseError: string | undefined; - - const linesIter = linesStream[Symbol.asyncIterator](); - const firstLineResult = await linesIter.next(); - - if (firstLineResult.done) { - // Empty stream - parseError = 'emptyResponse'; - tracer.warn(`Empty response stream, no edit_intent short name found`); - const remainingLinesStream: AsyncIterable = (async function* () { })(); - return { editIntent, remainingLinesStream, parseError }; - } - - const firstLine = firstLineResult.value.trim(); - - // Check if the first line is a single character short name - const parsedIntent = xtabPromptOptions.EditIntent.fromShortName(firstLine); - - if (parsedIntent !== undefined) { - editIntent = parsedIntent; - tracer.trace(`Parsed edit_intent short name from first line: "${firstLine}" -> ${editIntent}`); - - // Create a new stream with the remaining lines (excluding the short name line) - const remainingLinesStream: AsyncIterable = (async function* () { - let next = await linesIter.next(); - while (!next.done) { - yield next.value; - next = await linesIter.next(); - } - })(); - - return { editIntent, remainingLinesStream, parseError }; - } - - // Short name not found or invalid - parseError = `unknownIntentValue:${firstLine}`; - - tracer.warn(`Edit intent parse error: ${parseError} (using Xtab275EditIntentShort prompting strategy). ` + - `Defaulting to High (always show). First line was: "${firstLine.substring(0, 100)}..."`); - - // Return the first line plus the rest of the stream - const remainingLinesStream: AsyncIterable = (async function* () { - yield firstLineResult.value; // Use original value, not trimmed - let next = await linesIter.next(); - while (!next.done) { - yield next.value; - next = await linesIter.next(); - } - })(); - - return { editIntent, remainingLinesStream, parseError }; -} - -/** - * Parses the edit_intent tag from the first line of the response stream (original tag-based format). - */ -async function parseEditIntentFromStreamTags( - linesStream: AsyncIterable, - tracer: ILogger, -): Promise { - const EDIT_INTENT_START_TAG = '<|edit_intent|>'; - const EDIT_INTENT_END_TAG = '<|/edit_intent|>'; - - let editIntent: xtabPromptOptions.EditIntent = xtabPromptOptions.EditIntent.High; // Default to high (always show) if no tag found - let parseError: string | undefined; - - const linesIter = linesStream[Symbol.asyncIterator](); - const firstLineResult = await linesIter.next(); - - if (firstLineResult.done) { - // Empty stream - parseError = 'emptyResponse'; - tracer.warn(`Empty response stream, no edit_intent tag found`); - const remainingLinesStream: AsyncIterable = (async function* () { })(); - return { editIntent, remainingLinesStream, parseError }; - } - - const firstLine = firstLineResult.value; - - // Check if the first line contains the complete edit_intent tag - const startIdx = firstLine.indexOf(EDIT_INTENT_START_TAG); - const endIdx = firstLine.indexOf(EDIT_INTENT_END_TAG); - - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - // Found complete tag on first line - const intentValue = firstLine.substring( - startIdx + EDIT_INTENT_START_TAG.length, - endIdx - ).trim().toLowerCase(); - - // Check if it's a known intent value - const knownIntentValues = ['no_edit', 'low', 'medium', 'high']; - if (!knownIntentValues.includes(intentValue)) { - parseError = `unknownIntentValue:${intentValue}`; - tracer.warn(`Unknown edit_intent value: "${intentValue}", defaulting to High`); - } - - editIntent = xtabPromptOptions.EditIntent.fromString(intentValue); - tracer.trace(`Parsed edit_intent from first line: "${intentValue}" -> ${editIntent}`); - - // Calculate remaining content after the end tag on the first line - const afterEndTag = firstLine.substring(endIdx + EDIT_INTENT_END_TAG.length); - - // Create a new stream that first yields remaining content from first line, then continues - const remainingLinesStream: AsyncIterable = (async function* () { - // Only yield remaining content from first line if non-empty - if (afterEndTag.trim() !== '') { - yield afterEndTag; - } - // Continue with rest of the stream - let next = await linesIter.next(); - while (!next.done) { - yield next.value; - next = await linesIter.next(); - } - })(); - - return { editIntent, remainingLinesStream, parseError }; - } - - // Determine the parse error type - if (startIdx !== -1 && endIdx === -1) { - // Start tag found but no end tag - malformed (possibly split across lines) - parseError = 'malformedTag:startWithoutEnd'; - } else if (startIdx === -1 && endIdx !== -1) { - // End tag found but no start tag - malformed - parseError = 'malformedTag:endWithoutStart'; - } else { - // No tag found at all - parseError = 'noTagFound'; - } - - tracer.warn(`Edit intent parse error: ${parseError} (using Xtab275EditIntent prompting strategy). ` + - `Defaulting to High (always show). First line was: "${firstLine.substring(0, 100)}..."`); - - // Return the first line plus the rest of the stream - const remainingLinesStream: AsyncIterable = (async function* () { - yield firstLine; - let next = await linesIter.next(); - while (!next.done) { - yield next.value; - next = await linesIter.next(); - } - })(); - - return { editIntent, remainingLinesStream, parseError }; -} - /** * Finds the range of lines containing merge conflict markers within a specified edit window. * diff --git a/extensions/copilot/src/extension/xtab/test/node/editIntent.spec.ts b/extensions/copilot/src/extension/xtab/test/node/editIntent.spec.ts index 2d83fc8479ee2..2ea37c27686da 100644 --- a/extensions/copilot/src/extension/xtab/test/node/editIntent.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/editIntent.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AggressivenessLevel, EditIntent } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { ILogger } from '../../../../platform/log/common/logService'; import { AsyncIterUtils } from '../../../../util/common/asyncIterableUtils'; -import { EditIntentParseMode, parseEditIntentFromStream } from '../../node/xtabProvider'; +import { EditIntentParseMode, parseEditIntentFromStream } from '../../node/editIntent'; // ============================================================================ // Test Utilities From a636704f03120ad943c79b9ce9e8782a23f69a91 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Wed, 8 Apr 2026 15:53:28 +0200 Subject: [PATCH 15/15] nes: cleanup: extract finding merge conflict markers --- .../src/extension/xtab/node/xtabProvider.ts | 26 +- .../src/extension/xtab/node/xtabUtils.ts | 25 ++ .../xtab/test/node/xtabProvider.spec.ts | 286 ----------------- .../xtab/test/node/xtabUtils.spec.ts | 294 ++++++++++++++++++ 4 files changed, 320 insertions(+), 311 deletions(-) create mode 100644 extensions/copilot/src/extension/xtab/test/node/xtabUtils.spec.ts diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index 5afe4784b5417..3905a6f744b32 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -70,7 +70,7 @@ import { EditIntentParseMode, parseEditIntentFromStream } from './editIntent'; import { XtabCustomDiffPatchResponseHandler } from './xtabCustomDiffPatchResponseHandler'; import { XtabEndpoint } from './xtabEndpoint'; import { CursorJumpPrediction, XtabNextCursorPredictor } from './xtabNextCursorPredictor'; -import { charCount, constructMessages, linesWithBackticksRemoved } from './xtabUtils'; +import { charCount, constructMessages, findMergeConflictMarkersRange, linesWithBackticksRemoved } from './xtabUtils'; /** * Returns true if the user has made document edits since the request was created. @@ -1658,27 +1658,3 @@ export function getPredictionContents(doc: StatelessNextEditDocument, editWindow assertNever(responseFormat); } } - -/** - * Finds the range of lines containing merge conflict markers within a specified edit window. - * - * @param lines - Array of strings representing the lines of text to search through - * @param editWindowRange - The range within which to search for merge conflict markers - * @param maxMergeConflictLines - Maximum number of lines to search for conflict markers - * @returns An OffsetRange object representing the start and end of the conflict markers, or undefined if not found - */ -export function findMergeConflictMarkersRange(lines: string[], editWindowRange: OffsetRange, maxMergeConflictLines: number): OffsetRange | undefined { - for (let i = editWindowRange.start; i < Math.min(lines.length, editWindowRange.endExclusive); ++i) { - if (!lines[i].startsWith('<<<<<<<')) { - continue; - } - - // found start of merge conflict markers -- now find the end - for (let j = i + 1; j < lines.length && (j - i) < maxMergeConflictLines; ++j) { - if (lines[j].startsWith('>>>>>>>')) { - return new OffsetRange(i, j + 1 /* because endExclusive */); - } - } - } - return undefined; -} diff --git a/extensions/copilot/src/extension/xtab/node/xtabUtils.ts b/extensions/copilot/src/extension/xtab/node/xtabUtils.ts index 91f62c645d6f7..90af8b37d6269 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabUtils.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabUtils.ts @@ -5,6 +5,7 @@ import { Raw } from '@vscode/prompt-tsx'; import { toTextParts } from '../../../platform/chat/common/globalStringUtils'; +import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; /** @@ -57,3 +58,27 @@ export function charCount(messages: Raw.ChatMessage[]): number { const promptCharCount = messages.reduce((total, msg) => total + msg.content.reduce((subtotal, part) => subtotal + (part.type === Raw.ChatCompletionContentPartKind.Text ? part.text.length : 0), 0), 0); return promptCharCount; } +/** + * Finds the range of lines containing merge conflict markers within a specified edit window. + * + * @param lines - Array of strings representing the lines of text to search through + * @param editWindowRange - The range within which to search for merge conflict markers + * @param maxMergeConflictLines - Maximum number of lines to search for conflict markers + * @returns An OffsetRange object representing the start and end of the conflict markers, or undefined if not found + */ + +export function findMergeConflictMarkersRange(lines: string[], editWindowRange: OffsetRange, maxMergeConflictLines: number): OffsetRange | undefined { + for (let i = editWindowRange.start; i < Math.min(lines.length, editWindowRange.endExclusive); ++i) { + if (!lines[i].startsWith('<<<<<<<')) { + continue; + } + + // found start of merge conflict markers -- now find the end + for (let j = i + 1; j < lines.length && (j - i) < maxMergeConflictLines; ++j) { + if (lines[j].startsWith('>>>>>>>')) { + return new OffsetRange(i, j + 1 /* because endExclusive */); + } + } + } + return undefined; +} diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index a8c761492ecf0..a4e7d2611397c 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -45,7 +45,6 @@ import { computeAreaAroundEditWindowLinesRange, determineLanguageContextOptions, filterOutEditsWithSubstrings, - findMergeConflictMarkersRange, getPredictionContents, mapChatFetcherErrorToNoNextEditReason, ModelConfig, @@ -54,291 +53,6 @@ import { XtabProvider, } from '../../node/xtabProvider'; -suite('findMergeConflictMarkersRange', () => { - - test('should find merge conflict markers within edit window', () => { - const lines = [ - 'function foo() {', - '<<<<<<< HEAD', - ' return 1;', - '=======', - ' return 2;', - '>>>>>>> branch', - '}', - ]; - const editWindowRange = new OffsetRange(0, 7); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(1); - expect(result?.endExclusive).toBe(6); - }); - - test('should return undefined when no merge conflict markers present', () => { - const lines = [ - 'function foo() {', - ' return 1;', - '}', - ]; - const editWindowRange = new OffsetRange(0, 3); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); - }); - - test('should return undefined when start marker exists but no end marker', () => { - const lines = [ - 'function foo() {', - '<<<<<<< HEAD', - ' return 1;', - '=======', - ' return 2;', - '}', - ]; - const editWindowRange = new OffsetRange(0, 6); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); - }); - - test('should return undefined when conflict exceeds maxMergeConflictLines', () => { - const lines = [ - '<<<<<<< HEAD', - 'line 1', - 'line 2', - 'line 3', - 'line 4', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 6); - const maxMergeConflictLines = 3; // Too small to reach end marker - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); - }); - - test('should find conflict when exactly at maxMergeConflictLines boundary', () => { - const lines = [ - '<<<<<<< HEAD', - 'line 1', - 'line 2', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 4); - const maxMergeConflictLines = 4; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(0); - expect(result?.endExclusive).toBe(4); - }); - - test('should only search within edit window range', () => { - const lines = [ - 'function foo() {', - ' return 1;', - '<<<<<<< HEAD', - ' return 2;', - '>>>>>>> branch', - '}', - ]; - const editWindowRange = new OffsetRange(0, 2); // Excludes the conflict - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); - }); - - test('should find first conflict when multiple conflicts exist', () => { - const lines = [ - '<<<<<<< HEAD', - 'first conflict', - '>>>>>>> branch', - 'some code', - '<<<<<<< HEAD', - 'second conflict', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 7); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(0); - expect(result?.endExclusive).toBe(3); - }); - - test('should handle conflict at start of edit window', () => { - const lines = [ - '<<<<<<< HEAD', - 'content', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 3); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(0); - expect(result?.endExclusive).toBe(3); - }); - - test('should handle conflict at end of edit window', () => { - const lines = [ - 'some code', - '<<<<<<< HEAD', - 'content', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 4); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(1); - expect(result?.endExclusive).toBe(4); - }); - - test('should handle empty lines array', () => { - const lines: string[] = []; - const editWindowRange = new OffsetRange(0, 0); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); - }); - - test('should handle single line with start marker only', () => { - const lines = ['<<<<<<< HEAD']; - const editWindowRange = new OffsetRange(0, 1); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); - }); - - test('should handle lines with merge markers that do not start at beginning', () => { - const lines = [ - 'function foo() {', - ' <<<<<<< HEAD', - ' return 1;', - ' >>>>>>> branch', - '}', - ]; - const editWindowRange = new OffsetRange(0, 5); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); // Should not match as markers don't start at line beginning - }); - - test('should handle conflict that extends beyond lines array', () => { - const lines = [ - '<<<<<<< HEAD', - 'content', - ]; - const editWindowRange = new OffsetRange(0, 2); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); - }); - - test('should handle edit window extending beyond lines array', () => { - const lines = [ - '<<<<<<< HEAD', - 'content', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 100); // Beyond array length - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(0); - expect(result?.endExclusive).toBe(3); - }); - - test('should handle minimal conflict (start and end markers only)', () => { - const lines = [ - '<<<<<<< HEAD', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 2); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(0); - expect(result?.endExclusive).toBe(2); - }); - - test('should handle maxMergeConflictLines of 1', () => { - const lines = [ - '<<<<<<< HEAD', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 2); - const maxMergeConflictLines = 1; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeUndefined(); // Cannot find end marker within limit - }); - - test('should handle maxMergeConflictLines of 2', () => { - const lines = [ - '<<<<<<< HEAD', - '>>>>>>> branch', - ]; - const editWindowRange = new OffsetRange(0, 2); - const maxMergeConflictLines = 2; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(0); - expect(result?.endExclusive).toBe(2); - }); - - test('should find conflict starting in middle of edit window', () => { - const lines = [ - 'line 1', - 'line 2', - '<<<<<<< HEAD', - 'conflict', - '>>>>>>> branch', - 'line 5', - ]; - const editWindowRange = new OffsetRange(0, 6); - const maxMergeConflictLines = 10; - - const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); - - expect(result).toBeDefined(); - expect(result?.start).toBe(2); - expect(result?.endExclusive).toBe(5); - }); -}); - // ============================================================================ // Test Helpers // ============================================================================ diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabUtils.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabUtils.spec.ts new file mode 100644 index 0000000000000..52094765af00c --- /dev/null +++ b/extensions/copilot/src/extension/xtab/test/node/xtabUtils.spec.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect, suite, test } from 'vitest'; +import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange'; +import { findMergeConflictMarkersRange } from '../../node/xtabUtils'; + + +suite('findMergeConflictMarkersRange', () => { + + test('should find merge conflict markers within edit window', () => { + const lines = [ + 'function foo() {', + '<<<<<<< HEAD', + ' return 1;', + '=======', + ' return 2;', + '>>>>>>> branch', + '}', + ]; + const editWindowRange = new OffsetRange(0, 7); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(1); + expect(result?.endExclusive).toBe(6); + }); + + test('should return undefined when no merge conflict markers present', () => { + const lines = [ + 'function foo() {', + ' return 1;', + '}', + ]; + const editWindowRange = new OffsetRange(0, 3); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when start marker exists but no end marker', () => { + const lines = [ + 'function foo() {', + '<<<<<<< HEAD', + ' return 1;', + '=======', + ' return 2;', + '}', + ]; + const editWindowRange = new OffsetRange(0, 6); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when conflict exceeds maxMergeConflictLines', () => { + const lines = [ + '<<<<<<< HEAD', + 'line 1', + 'line 2', + 'line 3', + 'line 4', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 6); + const maxMergeConflictLines = 3; // Too small to reach end marker + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); + }); + + test('should find conflict when exactly at maxMergeConflictLines boundary', () => { + const lines = [ + '<<<<<<< HEAD', + 'line 1', + 'line 2', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 4); + const maxMergeConflictLines = 4; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(0); + expect(result?.endExclusive).toBe(4); + }); + + test('should only search within edit window range', () => { + const lines = [ + 'function foo() {', + ' return 1;', + '<<<<<<< HEAD', + ' return 2;', + '>>>>>>> branch', + '}', + ]; + const editWindowRange = new OffsetRange(0, 2); // Excludes the conflict + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); + }); + + test('should find first conflict when multiple conflicts exist', () => { + const lines = [ + '<<<<<<< HEAD', + 'first conflict', + '>>>>>>> branch', + 'some code', + '<<<<<<< HEAD', + 'second conflict', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 7); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(0); + expect(result?.endExclusive).toBe(3); + }); + + test('should handle conflict at start of edit window', () => { + const lines = [ + '<<<<<<< HEAD', + 'content', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 3); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(0); + expect(result?.endExclusive).toBe(3); + }); + + test('should handle conflict at end of edit window', () => { + const lines = [ + 'some code', + '<<<<<<< HEAD', + 'content', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 4); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(1); + expect(result?.endExclusive).toBe(4); + }); + + test('should handle empty lines array', () => { + const lines: string[] = []; + const editWindowRange = new OffsetRange(0, 0); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); + }); + + test('should handle single line with start marker only', () => { + const lines = ['<<<<<<< HEAD']; + const editWindowRange = new OffsetRange(0, 1); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); + }); + + test('should handle lines with merge markers that do not start at beginning', () => { + const lines = [ + 'function foo() {', + ' <<<<<<< HEAD', + ' return 1;', + ' >>>>>>> branch', + '}', + ]; + const editWindowRange = new OffsetRange(0, 5); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); // Should not match as markers don't start at line beginning + }); + + test('should handle conflict that extends beyond lines array', () => { + const lines = [ + '<<<<<<< HEAD', + 'content', + ]; + const editWindowRange = new OffsetRange(0, 2); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); + }); + + test('should handle edit window extending beyond lines array', () => { + const lines = [ + '<<<<<<< HEAD', + 'content', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 100); // Beyond array length + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(0); + expect(result?.endExclusive).toBe(3); + }); + + test('should handle minimal conflict (start and end markers only)', () => { + const lines = [ + '<<<<<<< HEAD', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 2); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(0); + expect(result?.endExclusive).toBe(2); + }); + + test('should handle maxMergeConflictLines of 1', () => { + const lines = [ + '<<<<<<< HEAD', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 2); + const maxMergeConflictLines = 1; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeUndefined(); // Cannot find end marker within limit + }); + + test('should handle maxMergeConflictLines of 2', () => { + const lines = [ + '<<<<<<< HEAD', + '>>>>>>> branch', + ]; + const editWindowRange = new OffsetRange(0, 2); + const maxMergeConflictLines = 2; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(0); + expect(result?.endExclusive).toBe(2); + }); + + test('should find conflict starting in middle of edit window', () => { + const lines = [ + 'line 1', + 'line 2', + '<<<<<<< HEAD', + 'conflict', + '>>>>>>> branch', + 'line 5', + ]; + const editWindowRange = new OffsetRange(0, 6); + const maxMergeConflictLines = 10; + + const result = findMergeConflictMarkersRange(lines, editWindowRange, maxMergeConflictLines); + + expect(result).toBeDefined(); + expect(result?.start).toBe(2); + expect(result?.endExclusive).toBe(5); + }); +});