diff --git a/.npmrc b/.npmrc index 060337bfad8..9409ee2f45d 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" target="39.2.7" -ms_build_id="12953945" +ms_build_id="13098910" runtime="electron" build_from_source="true" legacy-peer-deps="true" diff --git a/.vscode/settings.json b/.vscode/settings.json index bec2efbe491..bfb8f5e3e9a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -210,6 +210,6 @@ ], "azureMcp.serverMode": "all", "azureMcp.readOnly": true, - "chat.tools.terminal.outputLocation": "none", + "chat.tools.terminal.outputLocation": "chat", "debug.breakpointsView.presentation": "tree" } diff --git a/build/.moduleignore b/build/.moduleignore index 0459b46f743..ed36151130c 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -75,10 +75,10 @@ native-is-elevated/src/** native-is-elevated/deps/** !native-is-elevated/build/Release/*.node -native-watchdog/binding.gyp -native-watchdog/build/** -native-watchdog/src/** -!native-watchdog/build/Release/*.node +@vscode/native-watchdog/binding.gyp +@vscode/native-watchdog/build/** +@vscode/native-watchdog/src/** +!@vscode/native-watchdog/build/Release/*.node @vscode/vsce-sign/** !@vscode/vsce-sign/src/main.d.ts diff --git a/build/azure-pipelines/product-compile.yml b/build/azure-pipelines/product-compile.yml index 2aba62deea2..bc13d980df2 100644 --- a/build/azure-pipelines/product-compile.yml +++ b/build/azure-pipelines/product-compile.yml @@ -100,6 +100,23 @@ jobs: GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Compile & Hygiene + - script: | + set -e + + [ -d "out-build" ] || { echo "ERROR: out-build folder is missing" >&2; exit 1; } + [ -n "$(find out-build -mindepth 1 2>/dev/null | head -1)" ] || { echo "ERROR: out-build folder is empty" >&2; exit 1; } + echo "out-build exists and is not empty" + + ls -d out-vscode-* >/dev/null 2>&1 || { echo "ERROR: No out-vscode-* folders found" >&2; exit 1; } + for folder in out-vscode-*; do + [ -d "$folder" ] || { echo "ERROR: $folder is missing" >&2; exit 1; } + [ -n "$(find "$folder" -mindepth 1 2>/dev/null | head -1)" ] || { echo "ERROR: $folder is empty" >&2; exit 1; } + echo "$folder exists and is not empty" + done + + echo "All required compilation folders checked." + displayName: Validate compilation folders + - script: | set -e npm run compile diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 0f2e02380f8..62fc5399dbe 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -997,7 +997,8 @@ "--comment-thread-editor-font-weight", "--comment-thread-state-color", "--comment-thread-state-background-color", - "--inline-edit-border-radius" + "--inline-edit-border-radius", + "--chat-subagent-last-item-height" ], "sizes": [ "--vscode-bodyFontSize", diff --git a/build/linux/debian/dep-lists.ts b/build/linux/debian/dep-lists.ts index 941501b532c..46c257da4f7 100644 --- a/build/linux/debian/dep-lists.ts +++ b/build/linux/debian/dep-lists.ts @@ -33,6 +33,7 @@ export const referenceGeneratedDepsByArch = { 'libc6 (>= 2.2.5)', 'libc6 (>= 2.25)', 'libc6 (>= 2.28)', + 'libc6 (>= 2.4)', 'libcairo2 (>= 1.6.0)', 'libcurl3-gnutls | libcurl3-nss | libcurl4 | libcurl3', 'libdbus-1-3 (>= 1.9.14)', diff --git a/eslint.config.js b/eslint.config.js index 37fb7fe63bf..af29b3dba74 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -899,6 +899,7 @@ export default tseslint.config( ], 'verbs': [ 'accept', + 'archive', 'change', 'close', 'collapse', @@ -1444,6 +1445,7 @@ export default tseslint.config( '@vscode/vscode-languagedetection', '@vscode/ripgrep', '@vscode/iconv-lite-umd', + '@vscode/native-watchdog', '@vscode/policy-watcher', '@vscode/proxy-agent', '@vscode/spdlog', @@ -1462,7 +1464,6 @@ export default tseslint.config( 'minimist', 'node:module', 'native-keymap', - 'native-watchdog', 'net', 'node-pty', 'os', diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 79802e73668..8b361f66f61 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -51,6 +51,15 @@ "default": true, "title": "Focus Lock Indicator Enabled", "description": "%configuration.focusLockIndicator.enabled.description%" + }, + "simpleBrowser.useIntegratedBrowser": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.useIntegratedBrowser.description%", + "scope": "application", + "tags": [ + "experimental" + ] } } } diff --git a/extensions/simple-browser/package.nls.json b/extensions/simple-browser/package.nls.json index 496dc28dfdd..3b6b41530fa 100644 --- a/extensions/simple-browser/package.nls.json +++ b/extensions/simple-browser/package.nls.json @@ -1,5 +1,6 @@ { "displayName": "Simple Browser", "description": "A very basic built-in webview for displaying web content.", - "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser." + "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser.", + "configuration.useIntegratedBrowser.description": "When enabled, the `simpleBrowser.show` command will open URLs in the integrated browser instead of the Simple Browser webview. **Note:** This setting is experimental and only available on desktop." } diff --git a/extensions/simple-browser/src/extension.ts b/extensions/simple-browser/src/extension.ts index 885afe28712..6eb0bb0837f 100644 --- a/extensions/simple-browser/src/extension.ts +++ b/extensions/simple-browser/src/extension.ts @@ -14,6 +14,8 @@ declare class URL { const openApiCommand = 'simpleBrowser.api.open'; const showCommand = 'simpleBrowser.show'; +const integratedBrowserCommand = 'workbench.action.browser.open'; +const useIntegratedBrowserSetting = 'simpleBrowser.useIntegratedBrowser'; const enabledHosts = new Set([ 'localhost', @@ -31,6 +33,27 @@ const enabledHosts = new Set([ const openerId = 'simpleBrowser.open'; +/** + * Checks if the integrated browser should be used instead of the simple browser + */ +async function shouldUseIntegratedBrowser(): Promise { + const config = vscode.workspace.getConfiguration(); + if (!config.get(useIntegratedBrowserSetting, false)) { + return false; + } + + // Verify that the integrated browser command is available + const commands = await vscode.commands.getCommands(true); + return commands.includes(integratedBrowserCommand); +} + +/** + * Opens a URL in the integrated browser + */ +async function openInIntegratedBrowser(url?: string): Promise { + await vscode.commands.executeCommand(integratedBrowserCommand, url); +} + export function activate(context: vscode.ExtensionContext) { const manager = new SimpleBrowserManager(context.extensionUri); @@ -43,6 +66,10 @@ export function activate(context: vscode.ExtensionContext) { })); context.subscriptions.push(vscode.commands.registerCommand(showCommand, async (url?: string) => { + if (await shouldUseIntegratedBrowser()) { + return openInIntegratedBrowser(url); + } + if (!url) { url = await vscode.window.showInputBox({ placeHolder: vscode.l10n.t("https://example.com"), @@ -55,11 +82,15 @@ export function activate(context: vscode.ExtensionContext) { } })); - context.subscriptions.push(vscode.commands.registerCommand(openApiCommand, (url: vscode.Uri, showOptions?: { + context.subscriptions.push(vscode.commands.registerCommand(openApiCommand, async (url: vscode.Uri, showOptions?: { preserveFocus?: boolean; viewColumn: vscode.ViewColumn; }) => { - manager.show(url, showOptions); + if (await shouldUseIntegratedBrowser()) { + await openInIntegratedBrowser(url.toString(true)); + } else { + manager.show(url, showOptions); + } })); context.subscriptions.push(vscode.window.registerExternalUriOpener(openerId, { @@ -74,10 +105,14 @@ export function activate(context: vscode.ExtensionContext) { return vscode.ExternalUriOpenerPriority.None; }, - openExternalUri(resolveUri: vscode.Uri) { - return manager.show(resolveUri, { - viewColumn: vscode.window.activeTextEditor ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active - }); + async openExternalUri(resolveUri: vscode.Uri) { + if (await shouldUseIntegratedBrowser()) { + await openInIntegratedBrowser(resolveUri.toString(true)); + } else { + return manager.show(resolveUri, { + viewColumn: vscode.window.activeTextEditor ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active + }); + } } }, { schemes: ['http', 'https'], diff --git a/extensions/tunnel-forwarding/src/extension.ts b/extensions/tunnel-forwarding/src/extension.ts index 299c728719f..4752167e6f2 100644 --- a/extensions/tunnel-forwarding/src/extension.ts +++ b/extensions/tunnel-forwarding/src/extension.ts @@ -25,7 +25,11 @@ const cliPath = process.env.VSCODE_FORWARDING_IS_DEV ? path.join(__dirname, '../../../cli/target/debug/code') : path.join( vscode.env.appRoot, - process.platform === 'darwin' ? 'bin' : '../../bin', + process.platform === 'darwin' + ? 'bin' + : process.platform === 'win32' && vscode.env.appQuality === 'insider' + ? '../../../bin' // TODO: remove as part of https://github.com/microsoft/vscode/issues/282514 + : '../../bin', vscode.env.appQuality === 'stable' ? 'code-tunnel' : 'code-tunnel-insiders', ) + (process.platform === 'win32' ? '.exe' : ''); diff --git a/package-lock.json b/package-lock.json index b14a6d39f8e..2b2332b3383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/semver": "^7.5.8", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", + "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", @@ -45,11 +46,10 @@ "minimist": "^1.2.8", "native-is-elevated": "0.8.0", "native-keymap": "^3.3.5", - "native-watchdog": "^1.4.1", "node-pty": "^1.2.0-beta.6", "open": "^10.1.2", "tas-client": "0.3.1", - "undici": "^7.9.0", + "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", @@ -3258,6 +3258,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/@vscode/native-watchdog": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@vscode/native-watchdog/-/native-watchdog-1.4.6.tgz", + "integrity": "sha512-C2hsQFVYF2hBv7sa7OztRBimrMsEofGNh/lYs7MIPpKdhyJpYSpDb5iu/bilgLqSO61PLBCJ5xw6iFI21LI+9Q==", + "hasInstallScript": true, + "license": "MIT" + }, "node_modules/@vscode/policy-watcher": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.7.tgz", @@ -12825,12 +12832,6 @@ "hasInstallScript": true, "license": "MIT" }, - "node_modules/native-watchdog": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/native-watchdog/-/native-watchdog-1.4.2.tgz", - "integrity": "sha512-iT3Uj6FFdrW5vHbQ/ybiznLus9oiUoMJ8A8nyugXv9rV3EBhIodmGs+mztrwQyyBc+PB5/CrskAH/WxaUVRRSQ==", - "hasInstallScript": true - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -17233,9 +17234,9 @@ "dev": true }, "node_modules/undici": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.9.0.tgz", - "integrity": "sha512-e696y354tf5cFZPXsF26Yg+5M63+5H3oE6Vtkh2oqbvsE2Oe7s2nIbcQh5lmG7Lp/eS29vJtTpw9+p6PX0qNSg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 56fe0197794..44e6740de43 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.109.0", - "distro": "ce89ce05183635114ccfc46870d71ec520727c8e", + "distro": "f44449f84806363760ce8bb8dbe85cd8207498ff", "author": { "name": "Microsoft Corporation" }, @@ -79,6 +79,7 @@ "@types/semver": "^7.5.8", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", + "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", @@ -108,11 +109,10 @@ "minimist": "^1.2.8", "native-is-elevated": "0.8.0", "native-keymap": "^3.3.5", - "native-watchdog": "^1.4.1", "node-pty": "^1.2.0-beta.6", "open": "^10.1.2", "tas-client": "0.3.1", - "undici": "^7.9.0", + "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", diff --git a/remote/package-lock.json b/remote/package-lock.json index fd2b8a14bee..b651afca5e4 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -13,6 +13,7 @@ "@parcel/watcher": "^2.5.4", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", + "@vscode/native-watchdog": "^1.4.6", "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", @@ -37,7 +38,6 @@ "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.8", - "native-watchdog": "^1.4.1", "node-pty": "^1.2.0-beta.6", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", @@ -409,6 +409,13 @@ "integrity": "sha512-tK6k0DXFHW7q5+GGuGZO+phpAqpxO4WXl+BLc/8/uOk3RsM2ssAL3CQUQDb1TGfwltjsauhN6S4ghYZzs4sPFw==", "license": "MIT" }, + "node_modules/@vscode/native-watchdog": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@vscode/native-watchdog/-/native-watchdog-1.4.6.tgz", + "integrity": "sha512-C2hsQFVYF2hBv7sa7OztRBimrMsEofGNh/lYs7MIPpKdhyJpYSpDb5iu/bilgLqSO61PLBCJ5xw6iFI21LI+9Q==", + "hasInstallScript": true, + "license": "MIT" + }, "node_modules/@vscode/proxy-agent": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.36.0.tgz", @@ -1025,12 +1032,6 @@ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" }, - "node_modules/native-watchdog": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/native-watchdog/-/native-watchdog-1.4.2.tgz", - "integrity": "sha512-iT3Uj6FFdrW5vHbQ/ybiznLus9oiUoMJ8A8nyugXv9rV3EBhIodmGs+mztrwQyyBc+PB5/CrskAH/WxaUVRRSQ==", - "hasInstallScript": true - }, "node_modules/node-abi": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.8.0.tgz", diff --git a/remote/package.json b/remote/package.json index f506788e938..180f3b668f9 100644 --- a/remote/package.json +++ b/remote/package.json @@ -8,6 +8,7 @@ "@parcel/watcher": "^2.5.4", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", + "@vscode/native-watchdog": "^1.4.6", "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", @@ -32,7 +33,6 @@ "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.8", - "native-watchdog": "^1.4.1", "node-pty": "^1.2.0-beta.6", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", diff --git a/src/main.ts b/src/main.ts index ec2e45c31d2..fc2d71affbd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -227,7 +227,10 @@ function configureCommandlineSwitchesSync(cliArgs: NativeParsedArgs) { // bypass any specified proxy for the given semi-colon-separated list of hosts 'proxy-bypass-list', - 'remote-debugging-port' + 'remote-debugging-port', + + // Enable recovery from invalid Graphite recordings + 'enable-graphite-invalid-recording-recovery' ]; if (process.platform === 'linux') { @@ -374,6 +377,7 @@ interface IArgvConfig { readonly 'use-inmemory-secretstorage'?: boolean; readonly 'enable-rdp-display-tracking'?: boolean; readonly 'remote-debugging-port'?: string; + readonly 'enable-graphite-invalid-recording-recovery'?: boolean; } function readArgvConfigSync(): IArgvConfig { diff --git a/src/vs/base/browser/ui/button/button.css b/src/vs/base/browser/ui/button/button.css index da2318ec8b6..b641c7fc50c 100644 --- a/src/vs/base/browser/ui/button/button.css +++ b/src/vs/base/browser/ui/button/button.css @@ -46,7 +46,9 @@ .monaco-text-button.monaco-text-button-with-short-label { flex-direction: row; flex-wrap: wrap; + padding: 0 4px; overflow: hidden; + height: 28px; } .monaco-text-button.monaco-text-button-with-short-label > .monaco-button-label { @@ -66,6 +68,8 @@ align-items: center; font-weight: normal; font-style: inherit; + line-height: 18px; + padding: 4px 0; } .monaco-button-dropdown { diff --git a/src/vs/base/common/date.ts b/src/vs/base/common/date.ts index 3a169c2bdf5..e5887ef8bd3 100644 --- a/src/vs/base/common/date.ts +++ b/src/vs/base/common/date.ts @@ -24,6 +24,10 @@ const year = day * 365; * is less than 30 seconds. */ export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean, disallowNow?: boolean): string { + if (typeof date === 'undefined') { + return localize('date.fromNow.unknown', 'unknown'); + } + if (typeof date !== 'number') { date = date.getTime(); } diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index e31c45120fb..146bd1d690f 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -785,6 +785,53 @@ export function lcut(text: string, n: number, prefix = ''): string { return prefix + trimmed.substring(i).trimStart(); } +/** + * Given a string and a max length returns a shortened version keeping the beginning. + * Shortening happens at favorable positions - such as whitespace or punctuation characters. + * Trailing whitespace is always trimmed. + */ +export function rcut(text: string, n: number, suffix = ''): string { + const trimmed = text.trimEnd(); + + if (trimmed.length <= n) { + return trimmed; + } + + const re = /\b/g; + let lastGoodBreak = 0; + let foundBoundaryAfterN = false; + while (re.test(trimmed)) { + if (re.lastIndex > n) { + foundBoundaryAfterN = true; + break; + } + lastGoodBreak = re.lastIndex; + re.lastIndex += 1; + } + + // If no boundary was found after n, return the full trimmed string + // (there's no good place to cut) + if (!foundBoundaryAfterN) { + return trimmed; + } + + // If the only boundary <= n is at position 0 (start of string), + // cutting there gives empty string, so just return the suffix + if (lastGoodBreak === 0) { + return suffix; + } + + const result = trimmed.substring(0, lastGoodBreak).trimEnd(); + + // If trimEnd removed more than half of what we cut (meaning we cut + // mostly through whitespace), return the full string instead + if (result.length < lastGoodBreak / 2) { + return trimmed; + } + + return result + suffix; +} + // Defacto standard: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/; const OSC_SEQUENCE = /(?:\x1b\]|\x9d).*?(?:\x1b\\|\x07|\x9c)/; diff --git a/src/vs/base/test/common/strings.test.ts b/src/vs/base/test/common/strings.test.ts index bb992038f19..aeac4aa7b16 100644 --- a/src/vs/base/test/common/strings.test.ts +++ b/src/vs/base/test/common/strings.test.ts @@ -196,6 +196,40 @@ suite('Strings', () => { assert.strictEqual(strings.lcut('............a', 10, '…'), '............a'); }); + test('rcut', () => { + assert.strictEqual(strings.rcut('foo bar', 0), ''); + assert.strictEqual(strings.rcut('foo bar', 1), ''); + assert.strictEqual(strings.rcut('foo bar', 3), 'foo'); + assert.strictEqual(strings.rcut('foo bar', 4), 'foo'); // Trailing whitespace trimmed + assert.strictEqual(strings.rcut('foo bar', 5), 'foo'); + assert.strictEqual(strings.rcut('foo bar', 7), 'foo bar'); + assert.strictEqual(strings.rcut('foo bar', 10), 'foo bar'); + assert.strictEqual(strings.rcut('test string 0.1.2.3', 6), 'test'); + + assert.strictEqual(strings.rcut('foo bar', 0, '…'), '…'); + assert.strictEqual(strings.rcut('foo bar', 1, '…'), '…'); + assert.strictEqual(strings.rcut('foo bar', 3, '…'), 'foo…'); + assert.strictEqual(strings.rcut('foo bar', 4, '…'), 'foo…'); // Trailing whitespace trimmed + assert.strictEqual(strings.rcut('foo bar', 5, '…'), 'foo…'); + assert.strictEqual(strings.rcut('foo bar', 7, '…'), 'foo bar'); + assert.strictEqual(strings.rcut('foo bar', 10, '…'), 'foo bar'); + assert.strictEqual(strings.rcut('test string 0.1.2.3', 6, '…'), 'test…'); + + assert.strictEqual(strings.rcut('', 10), ''); + assert.strictEqual(strings.rcut('a', 10), 'a'); + assert.strictEqual(strings.rcut('a ', 10), 'a'); + assert.strictEqual(strings.rcut('a ', 10), 'a'); + assert.strictEqual(strings.rcut('a bbbb ', 10), 'a bbbb'); + assert.strictEqual(strings.rcut('a............', 10), 'a............'); + + assert.strictEqual(strings.rcut('', 10, '…'), ''); + assert.strictEqual(strings.rcut('a', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a ', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a ', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a bbbb ', 10, '…'), 'a bbbb'); + assert.strictEqual(strings.rcut('a............', 10, '…'), 'a............'); + }); + test('escape', () => { assert.strictEqual(strings.escape(''), ''); assert.strictEqual(strings.escape('foo'), 'foo'); diff --git a/src/vs/editor/browser/viewParts/rulers/rulers.css b/src/vs/editor/browser/viewParts/rulers/rulers.css index aad356bd749..17a9da155d0 100644 --- a/src/vs/editor/browser/viewParts/rulers/rulers.css +++ b/src/vs/editor/browser/viewParts/rulers/rulers.css @@ -7,4 +7,5 @@ position: absolute; top: 0; box-shadow: 1px 0 0 0 var(--vscode-editorRuler-foreground) inset; + pointer-events: none; } diff --git a/src/vs/editor/browser/viewParts/rulers/rulers.ts b/src/vs/editor/browser/viewParts/rulers/rulers.ts index f34f20f43a9..ec1a5042e91 100644 --- a/src/vs/editor/browser/viewParts/rulers/rulers.ts +++ b/src/vs/editor/browser/viewParts/rulers/rulers.ts @@ -70,7 +70,7 @@ export class Rulers extends ViewPart { while (addCount > 0) { const node = createFastDomNode(document.createElement('div')); node.setClassName('view-ruler'); - node.setWidth('1px'); + node.setWidth('1ch'); this.domNode.appendChild(node); this._renderedRulers.push(node); addCount--; diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 41d1c8e9522..a6e0856069e 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions } from '../common/browserView.js'; -import { EVENT_KEY_CODE_MAP, KeyCode, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; +import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; @@ -17,19 +17,17 @@ import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryW import { ILogService } from '../../log/common/log.js'; import { isMacintosh } from '../../../base/common/platform.js'; -const nativeShortcutKeys = new Set(['KeyA', 'KeyC', 'KeyV', 'KeyX', 'KeyZ']); -function shouldIgnoreNativeShortcut(input: Electron.Input): boolean { - const isControlInput = isMacintosh ? input.meta : input.control; - const isAltOnlyInput = input.alt && !input.control && !input.meta; - - // Ignore Alt-only inputs (often used for accented characters or menu accelerators) - if (isAltOnlyInput) { - return true; - } - - // Ignore Ctrl/Cmd + [A,C,V,X,Z] shortcuts to allow native handling (e.g. copy/paste) - return isControlInput && nativeShortcutKeys.has(input.code); -} +/** Key combinations that are used in system-level shortcuts. */ +const nativeShortcuts = new Set([ + KeyMod.CtrlCmd | KeyCode.KeyA, + KeyMod.CtrlCmd | KeyCode.KeyC, + KeyMod.CtrlCmd | KeyCode.KeyV, + KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyV, + KeyMod.CtrlCmd | KeyCode.KeyX, + ...(isMacintosh ? [] : [KeyMod.CtrlCmd | KeyCode.KeyY]), + KeyMod.CtrlCmd | KeyCode.KeyZ, + KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ +]); /** * Represents a single browser view instance with its WebContentsView and all associated logic. @@ -237,28 +235,8 @@ export class BrowserView extends Disposable { // Key down events - listen for raw key input events webContents.on('before-input-event', async (event, input) => { if (input.type === 'keyDown' && !this._isSendingKeyEvent) { - if (shouldIgnoreNativeShortcut(input)) { - return; - } - const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; - const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown; - const hasCommandModifier = input.control || input.alt || input.meta; - const isNonEditingKey = - keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 || - keyCode >= KeyCode.AudioVolumeMute; - - if (hasCommandModifier || isNonEditingKey) { + if (this.tryHandleCommand(input)) { event.preventDefault(); - this._onDidKeyCommand.fire({ - key: input.key, - keyCode: eventKeyCode, - code: input.code, - ctrlKey: input.control || false, - shiftKey: input.shift || false, - altKey: input.alt || false, - metaKey: input.meta || false, - repeat: input.isAutoRepeat || false - }); } } }); @@ -467,6 +445,54 @@ export class BrowserView extends Disposable { super.dispose(); } + /** + * Potentially handle an input event as a VS Code command. + * Returns `true` if the event was forwarded to VS Code and should not be handled natively. + */ + private tryHandleCommand(input: Electron.Input): boolean { + const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; + const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown; + + const isArrowKey = keyCode >= KeyCode.LeftArrow && keyCode <= KeyCode.DownArrow; + const isNonEditingKey = + keyCode === KeyCode.Escape || + keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 || + keyCode >= KeyCode.AudioVolumeMute; + + // Ignore most Alt-only inputs (often used for accented characters or menu accelerators) + const isAltOnlyInput = input.alt && !input.control && !input.meta; + if (isAltOnlyInput && !isNonEditingKey && !isArrowKey) { + return false; + } + + // Only reroute if there's a command modifier or it's a non-editing key + const hasCommandModifier = input.control || input.alt || input.meta; + if (!hasCommandModifier && !isNonEditingKey) { + return false; + } + + // Ignore Ctrl/Cmd + [A,C,V,X,Z] shortcuts to allow native handling (e.g. copy/paste) + const isControlInput = isMacintosh ? input.meta : input.control; + const modifiedKeyCode = keyCode | + (isControlInput ? KeyMod.CtrlCmd : 0) | + (input.shift ? KeyMod.Shift : 0) | + (input.alt ? KeyMod.Alt : 0); + if (nativeShortcuts.has(modifiedKeyCode)) { + return false; + } + + this._onDidKeyCommand.fire({ + key: input.key, + keyCode: eventKeyCode, + code: input.code, + ctrlKey: input.control || false, + shiftKey: input.shift || false, + altKey: input.alt || false, + metaKey: input.meta || false, + repeat: input.isAutoRepeat || false + }); + return true; + } private windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId); diff --git a/src/vs/platform/diagnostics/common/diagnostics.ts b/src/vs/platform/diagnostics/common/diagnostics.ts index d2a5432d4ed..bdb36c2a799 100644 --- a/src/vs/platform/diagnostics/common/diagnostics.ts +++ b/src/vs/platform/diagnostics/common/diagnostics.ts @@ -142,6 +142,11 @@ export interface IProcessDiagnostics { readonly name: string; } +export interface IGPULogMessage { + readonly header: string; + readonly message: string; +} + export interface IMainProcessDiagnostics { readonly mainPID: number; readonly mainArguments: string[]; // All arguments after argv[0], the exec path @@ -149,4 +154,5 @@ export interface IMainProcessDiagnostics { readonly pidToNames: IProcessDiagnostics[]; readonly screenReader: boolean; readonly gpuFeatureStatus: any; + readonly gpuLogMessages: IGPULogMessage[]; } diff --git a/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts b/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts index 72e9060db8c..d128f47f473 100644 --- a/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts +++ b/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts @@ -7,7 +7,7 @@ import { app, BrowserWindow, Event as IpcEvent } from 'electron'; import { validatedIpcMain } from '../../../base/parts/ipc/electron-main/ipcMain.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; -import { IDiagnosticInfo, IDiagnosticInfoOptions, IMainProcessDiagnostics, IProcessDiagnostics, IRemoteDiagnosticError, IRemoteDiagnosticInfo, IWindowDiagnostics } from '../common/diagnostics.js'; +import { IDiagnosticInfo, IDiagnosticInfoOptions, IGPULogMessage, IMainProcessDiagnostics, IProcessDiagnostics, IRemoteDiagnosticError, IRemoteDiagnosticInfo, IWindowDiagnostics } from '../common/diagnostics.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { getAllWindowsExcludingOffscreen, IWindowsMainService } from '../../windows/electron-main/windows.js'; @@ -94,13 +94,24 @@ export class DiagnosticsMainService implements IDiagnosticsMainService { pidToNames.push({ pid, name }); } + type AppWithGPULogMethod = typeof app & { + getGPULogMessages(): IGPULogMessage[]; + }; + + let gpuLogMessages: IGPULogMessage[] = []; + const customApp = app as AppWithGPULogMethod; + if (typeof customApp.getGPULogMessages === 'function') { + gpuLogMessages = customApp.getGPULogMessages(); + } + return { mainPID: process.pid, mainArguments: process.argv.slice(1), windows, pidToNames, screenReader: !!app.accessibilitySupportEnabled, - gpuFeatureStatus: app.getGPUFeatureStatus() + gpuFeatureStatus: app.getGPUFeatureStatus(), + gpuLogMessages }; } diff --git a/src/vs/platform/diagnostics/node/diagnosticsService.ts b/src/vs/platform/diagnostics/node/diagnosticsService.ts index ea57c9326e4..5a424bb9ff0 100644 --- a/src/vs/platform/diagnostics/node/diagnosticsService.ts +++ b/src/vs/platform/diagnostics/node/diagnosticsService.ts @@ -255,6 +255,12 @@ export class DiagnosticsService implements IDiagnosticsService { output.push(`Screen Reader: ${info.screenReader ? 'yes' : 'no'}`); output.push(`Process Argv: ${info.mainArguments.join(' ')}`); output.push(`GPU Status: ${this.expandGPUFeatures(info.gpuFeatureStatus)}`); + if (info.gpuLogMessages && info.gpuLogMessages.length > 0) { + output.push(`GPU Log Messages:`); + info.gpuLogMessages.forEach(msg => { + output.push(`${msg.header}: ${msg.message}`); + }); + } return output.join('\n'); } diff --git a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts index 50999154d16..c30c6da5f0b 100644 --- a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts +++ b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts @@ -50,9 +50,9 @@ flakySuite('Native Modules (all platforms)', () => { assert.ok(result, testErrorMessage('native-keymap')); }); - test('native-watchdog', async () => { - const watchDog = await import('native-watchdog'); - assert.ok(typeof watchDog.start === 'function', testErrorMessage('native-watchdog')); + test('@vscode/native-watchdog', async () => { + const watchDog = await import('@vscode/native-watchdog'); + assert.ok(typeof watchDog.start === 'function', testErrorMessage('@vscode/native-watchdog')); }); test('@vscode/sudo-prompt', async () => { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 51d240a6947..d3ab457a7a2 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -182,6 +182,9 @@ const _allApiProposals = { contribViewsWelcome: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsWelcome.d.ts', }, + css: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.css.d.ts', + }, customEditorMove: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts', }, diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 31f148f2024..bfb284d9511 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -13,6 +13,7 @@ import { IconExtensionPoint } from '../../services/themes/common/iconExtensionPo import { TokenClassificationExtensionPoints } from '../../services/themes/common/tokenClassificationExtensionPoint.js'; import { LanguageConfigurationFileHandler } from '../../contrib/codeEditor/common/languageConfigurationExtensionPoint.js'; import { StatusBarItemsExtensionPoint } from './statusBarExtensionPoint.js'; +import { CSSExtensionPoint } from '../../services/themes/browser/cssExtensionPoint.js'; // --- mainThread participants import './mainThreadLocalization.js'; @@ -110,6 +111,7 @@ export class ExtensionPoints implements IWorkbenchContribution { this.instantiationService.createInstance(TokenClassificationExtensionPoints); this.instantiationService.createInstance(LanguageConfigurationFileHandler); this.instantiationService.createInstance(StatusBarItemsExtensionPoint); + this.instantiationService.createInstance(CSSExtensionPoint); } } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 0d56d40bb74..de412e896a2 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -288,6 +288,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA toolId: progress.toolName, chatRequestId: requestId, sessionResource: chatSession?.sessionResource, + subagentInvocationId: progress.subagentInvocationId }); continue; } diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 6a18a39b05f..38de78caf4a 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -382,7 +382,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } - $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } @@ -491,6 +490,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat resource: uri, iconPath: session.iconPath, tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, + archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts index bd52b436bfa..2e5aee4cd48 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts @@ -125,7 +125,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre return fn.countTokens(input, token); } - $registerTool(id: string): void { + $registerTool(id: string, hasHandleToolStream: boolean): void { const disposable = this._languageModelToolsService.registerToolImplementation( id, { @@ -140,12 +140,12 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre } }, prepareToolInvocation: (context, token) => this._proxy.$prepareToolInvocation(id, context, token), - handleToolStream: (context, token) => this._proxy.$handleToolStream(id, context, token), + handleToolStream: hasHandleToolStream ? (context, token) => this._proxy.$handleToolStream(id, context, token) : undefined, }); this._tools.set(id, disposable); } - $registerToolWithDefinition(definition: IToolDefinitionDto): void { + $registerToolWithDefinition(definition: IToolDefinitionDto, hasHandleToolStream: boolean): void { let icon: IToolData['icon'] | undefined; if (definition.icon) { if (ThemeIcon.isThemeIcon(definition.icon)) { @@ -199,6 +199,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre this._runningToolCalls.delete(dto.callId); } }, + handleToolStream: hasHandleToolStream ? (context, token) => this._proxy.$handleToolStream(id, context, token) : undefined, prepareToolInvocation: (context, token) => this._proxy.$prepareToolInvocation(id, context, token), } ); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 69162e12ea6..32cbebafb60 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1530,6 +1530,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, + createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); + }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 1d3d6debfbe..b4830b6c683 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1510,8 +1510,8 @@ export interface MainThreadLanguageModelToolsShape extends IDisposable { $acceptToolProgress(callId: string, progress: IToolProgressStep): void; $invokeTool(dto: Dto, token?: CancellationToken): Promise | SerializableObjectWithBuffers>>; $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise; - $registerTool(id: string): void; - $registerToolWithDefinition(definition: IToolDefinitionDto): void; + $registerTool(id: string, hasHandleToolStream: boolean): void; + $registerToolWithDefinition(definition: IToolDefinitionDto, hasHandleToolStream: boolean): void; $unregisterTool(name: string): void; } @@ -2343,6 +2343,7 @@ export interface IChatBeginToolInvocationDto { streamData?: { partialInput?: unknown; }; + subagentInvocationId?: string; } export interface IChatUpdateToolInvocationDto { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 785c6bf33ef..64b4e08aa4d 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -311,7 +311,8 @@ export class ChatAgentResponseStream { toolName, streamData: streamData ? { partialInput: streamData.partialInput - } : undefined + } : undefined, + subagentInvocationId: streamData?.subagentInvocationId }; _report(dto); return this; @@ -559,6 +560,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return await (provider as vscode.InstructionsProvider).provideInstructions(context, token) ?? undefined; case PromptsType.prompt: return await (provider as vscode.PromptFileProvider).providePromptFiles(context, token) ?? undefined; + case PromptsType.skill: + throw new Error('Skills prompt file provider not implemented yet'); } } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index bc7366256c1..c4d34921e45 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,12 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -29,6 +31,177 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; +type ChatSessionTiming = vscode.ChatSessionItem['timing']; + +// #region Chat Session Item Controller + +class ChatSessionItemImpl implements vscode.ChatSessionItem { + #label: string; + #iconPath?: vscode.IconPath; + #description?: string | vscode.MarkdownString; + #badge?: string | vscode.MarkdownString; + #status?: vscode.ChatSessionStatus; + #archived?: boolean; + #tooltip?: string | vscode.MarkdownString; + #timing?: ChatSessionTiming; + #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; + #onChanged: () => void; + + readonly resource: vscode.Uri; + + constructor(resource: vscode.Uri, label: string, onChanged: () => void) { + this.resource = resource; + this.#label = label; + this.#onChanged = onChanged; + } + + get label(): string { + return this.#label; + } + + set label(value: string) { + if (this.#label !== value) { + this.#label = value; + this.#onChanged(); + } + } + + get iconPath(): vscode.IconPath | undefined { + return this.#iconPath; + } + + set iconPath(value: vscode.IconPath | undefined) { + if (this.#iconPath !== value) { + this.#iconPath = value; + this.#onChanged(); + } + } + + get description(): string | vscode.MarkdownString | undefined { + return this.#description; + } + + set description(value: string | vscode.MarkdownString | undefined) { + if (this.#description !== value) { + this.#description = value; + this.#onChanged(); + } + } + + get badge(): string | vscode.MarkdownString | undefined { + return this.#badge; + } + + set badge(value: string | vscode.MarkdownString | undefined) { + if (this.#badge !== value) { + this.#badge = value; + this.#onChanged(); + } + } + + get status(): vscode.ChatSessionStatus | undefined { + return this.#status; + } + + set status(value: vscode.ChatSessionStatus | undefined) { + if (this.#status !== value) { + this.#status = value; + this.#onChanged(); + } + } + + get archived(): boolean | undefined { + return this.#archived; + } + + set archived(value: boolean | undefined) { + if (this.#archived !== value) { + this.#archived = value; + this.#onChanged(); + } + } + + get tooltip(): string | vscode.MarkdownString | undefined { + return this.#tooltip; + } + + set tooltip(value: string | vscode.MarkdownString | undefined) { + if (this.#tooltip !== value) { + this.#tooltip = value; + this.#onChanged(); + } + } + + get timing(): ChatSessionTiming | undefined { + return this.#timing; + } + + set timing(value: ChatSessionTiming | undefined) { + if (this.#timing !== value) { + this.#timing = value; + this.#onChanged(); + } + } + + get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { + return this.#changes; + } + + set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { + if (this.#changes !== value) { + this.#changes = value; + this.#onChanged(); + } + } +} + +class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { + readonly #items = new ResourceMap(); + #onItemsChanged: () => void; + + constructor(onItemsChanged: () => void) { + this.#onItemsChanged = onItemsChanged; + } + + get size(): number { + return this.#items.size; + } + + replace(items: readonly vscode.ChatSessionItem[]): void { + this.#items.clear(); + for (const item of items) { + this.#items.set(item.resource, item); + } + this.#onItemsChanged(); + } + + forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { + for (const [_, item] of this.#items) { + callback.call(thisArg, item, this); + } + } + + add(item: vscode.ChatSessionItem): void { + this.#items.set(item.resource, item); + this.#onItemsChanged(); + } + + delete(resource: vscode.Uri): void { + this.#items.delete(resource); + this.#onItemsChanged(); + } + + get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { + return this.#items.get(resource); + } + + [Symbol.iterator](): Iterator { + return this.#items.entries(); + } +} + +// #endregion + class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -62,13 +235,20 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly extension: IExtensionDescription; readonly disposable: DisposableStore; }>(); + private readonly _chatSessionItemControllers = new Map(); + private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map(); - private _nextChatSessionItemProviderHandle = 0; + private _nextChatSessionItemControllerHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -140,6 +320,52 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } + + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { + const controllerHandle = this._nextChatSessionItemControllerHandle++; + const disposables = new DisposableStore(); + + // TODO: Currently not hooked up + const onDidArchiveChatSessionItem = disposables.add(new Emitter()); + + const collection = new ChatSessionItemCollectionImpl(() => { + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + + let isDisposed = false; + + const controller: vscode.ChatSessionItemController = { + id, + refreshHandler, + items: collection, + onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, + createChatSessionItem: (resource: vscode.Uri, label: string) => { + if (isDisposed) { + throw new Error('ChatSessionItemController has been disposed'); + } + + return new ChatSessionItemImpl(resource, label, () => { + // TODO: Optimize to only update the specific item + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + }, + dispose: () => { + isDisposed = true; + disposables.dispose(); + }, + }; + + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(controllerHandle, id); + + disposables.add(toDisposable(() => { + this._chatSessionItemControllers.delete(controllerHandle); + this._proxy.$unregisterChatSessionItemProvider(controllerHandle); + })); + + return controller; + } + registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { const handle = this._nextChatSessionContentProviderHandle++; const disposables = new DisposableStore(); @@ -184,17 +410,25 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } - private convertChatSessionItem(sessionType: string, sessionContent: vscode.ChatSessionItem): IChatSessionItem { + private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + return { resource: sessionContent.resource, label: sessionContent.label, description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, status: this.convertChatSessionStatus(sessionContent.status), + archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { - startTime: sessionContent.timing?.startTime ?? 0, - endTime: sessionContent.timing?.endTime + created, + lastRequestStarted, + lastRequestEnded, }, changes: sessionContent.changes instanceof Array ? sessionContent.changes : @@ -207,21 +441,35 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemProviders.get(handle); - if (!entry) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } + let items: vscode.ChatSessionItem[]; - const sessions = await entry.provider.provideChatSessionItems(token); - if (!sessions) { - return []; + const controller = this._chatSessionItemControllers.get(handle); + if (controller) { + // Call the refresh handler to populate items + await controller.controller.refreshHandler(); + if (token.isCancellationRequested) { + return []; + } + + items = Array.from(controller.controller.items, x => x[1]); + } else { + + const itemProvider = this._chatSessionItemProviders.get(handle); + if (!itemProvider) { + this._logService.error(`No provider registered for handle ${handle}`); + return []; + } + + items = await itemProvider.provider.provideChatSessionItems(token) ?? []; + if (token.isCancellationRequested) { + return []; + } } const response: IChatSessionItem[] = []; - for (const sessionContent of sessions) { + for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); + response.push(this.convertChatSessionItem(sessionContent)); } return response; } diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 21815d734b5..395c2c4e1fd 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -126,7 +126,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape context: options.toolInvocationToken as IToolInvocationContext | undefined, chatRequestId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatRequestId : undefined, chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, - fromSubAgent: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.fromSubAgent : undefined, + subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, }, token); @@ -187,7 +187,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; options.chatSessionId = dto.context?.sessionId; - options.fromSubAgent = dto.fromSubAgent; + options.subAgentInvocationId = dto.subAgentInvocationId; } if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { @@ -316,7 +316,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape registerTool(extension: IExtensionDescription, id: string, tool: vscode.LanguageModelTool): IDisposable { this._registeredTools.set(id, { extension, tool }); - this._proxy.$registerTool(id); + this._proxy.$registerTool(id, typeof tool.handleToolStream === 'function'); return toDisposable(() => { this._registeredTools.delete(id); @@ -347,7 +347,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape }; this._registeredTools.set(id, { extension, tool }); - this._proxy.$registerToolWithDefinition(dto); + this._proxy.$registerToolWithDefinition(dto, typeof tool.handleToolStream === 'function'); return toDisposable(() => { this._registeredTools.delete(id); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index bd1cdbd286f..b07a19a40cb 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -2833,7 +2833,7 @@ export namespace ChatToolInvocationPart { : part.presentation === 'hiddenAfterComplete' ? ToolInvocationPresentation.HiddenAfterComplete : undefined, - fromSubAgent: part.fromSubAgent + subAgentInvocationId: part.subAgentInvocationId }; } @@ -2882,7 +2882,7 @@ export namespace ChatToolInvocationPart { if (part.toolSpecificData) { toolInvocation.toolSpecificData = convertFromInternalToolSpecificData(part.toolSpecificData); } - toolInvocation.fromSubAgent = part.fromSubAgent; + toolInvocation.subAgentInvocationId = part.subAgentInvocationId; return toolInvocation; } @@ -3161,7 +3161,7 @@ export namespace ChatAgentRequest { editedFileEvents: request.editedFileEvents, modeInstructions: request.modeInstructions?.content, modeInstructions2: ChatRequestModeInstructions.to(request.modeInstructions), - isSubagent: request.isSubagent, + subAgentInvocationId: request.subAgentInvocationId, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3182,7 +3182,7 @@ export namespace ChatAgentRequest { // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).sessionId; // eslint-disable-next-line local/code-no-any-casts - delete (requestWithAllProps as any).isSubagent; + delete (requestWithAllProps as any).subAgentInvocationId; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 6277175ffcd..b8d92947c99 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3359,7 +3359,7 @@ export class ChatToolInvocationPart { isConfirmed?: boolean; isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData2; - fromSubAgent?: boolean; + subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; constructor(toolName: string, diff --git a/src/vs/workbench/api/node/extensionHostProcess.ts b/src/vs/workbench/api/node/extensionHostProcess.ts index db779d8fd3f..bb0fb8eec64 100644 --- a/src/vs/workbench/api/node/extensionHostProcess.ts +++ b/src/vs/workbench/api/node/extensionHostProcess.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import minimist from 'minimist'; -import * as nativeWatchdog from 'native-watchdog'; +import * as nativeWatchdog from '@vscode/native-watchdog'; import * as net from 'net'; import { ProcessTimeRunOnceScheduler } from '../../../base/common/async.js'; import { VSBuffer } from '../../../base/common/buffer.js'; @@ -375,7 +375,7 @@ function connectToRenderer(protocol: IMessagePassingProtocol): Promise group.startsWith('actions'), useSeparatorsInPrimaryActions: true }, menuOptions: { shouldForwardArgs: true } } )); @@ -155,7 +156,9 @@ export class BrowserEditor extends EditorPane { private _navigationBar!: BrowserNavigationBar; private _browserContainer!: HTMLElement; + private _placeholderScreenshot!: HTMLElement; private _errorContainer!: HTMLElement; + private _welcomeContainer!: HTMLElement; private _canGoBackContext!: IContextKey; private _canGoForwardContext!: IContextKey; private _storageScopeContext!: IContextKey; @@ -218,11 +221,19 @@ export class BrowserEditor extends EditorPane { this._browserContainer.tabIndex = 0; // make focusable root.appendChild(this._browserContainer); + // Create placeholder screenshot (background placeholder when WebContentsView is hidden) + this._placeholderScreenshot = $('.browser-placeholder-screenshot'); + this._browserContainer.appendChild(this._placeholderScreenshot); + // Create error container (hidden by default) this._errorContainer = $('.browser-error-container'); this._errorContainer.style.display = 'none'; this._browserContainer.appendChild(this._errorContainer); + // Create welcome container (shown when no URL is loaded) + this._welcomeContainer = this.createWelcomeContainer(); + this._browserContainer.appendChild(this._welcomeContainer); + this._register(addDisposableListener(this._browserContainer, EventType.FOCUS, (event) => { // When the browser container gets focus, make sure the browser view also gets focused. // But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view). @@ -362,15 +373,24 @@ export class BrowserEditor extends EditorPane { } private updateVisibility(): void { + const hasUrl = !!this._model?.url; + const hasError = !!this._model?.error; + + // Welcome container: shown when no URL is loaded + this._welcomeContainer.style.display = hasUrl ? 'none' : 'flex'; + + // Error container: shown when there's a load error + this._errorContainer.style.display = hasError ? 'flex' : 'none'; + if (this._model) { - // Blur the background image if the view is hidden due to an overlay. - this._browserContainer.classList.toggle('blur', this._editorVisible && this._overlayVisible && !this._model?.error); + // Blur the background placeholder screenshot if the view is hidden due to an overlay. + this._placeholderScreenshot.classList.toggle('blur', this._editorVisible && this._overlayVisible && !hasError); void this._model.setVisible(this.shouldShowView); } } private get shouldShowView(): boolean { - return this._editorVisible && !this._overlayVisible && !this._model?.error; + return this._editorVisible && !this._overlayVisible && !this._model?.error && !!this._model?.url; } private checkOverlays(): void { @@ -391,8 +411,7 @@ export class BrowserEditor extends EditorPane { const error: IBrowserViewLoadError | undefined = this._model.error; if (error) { - // Show error display - this._errorContainer.style.display = 'flex'; + // Update error content while (this._errorContainer.firstChild) { this._errorContainer.removeChild(this._errorContainer.firstChild); @@ -423,14 +442,16 @@ export class BrowserEditor extends EditorPane { this.setBackgroundImage(undefined); } else { - // Hide error display - this._errorContainer.style.display = 'none'; this.setBackgroundImage(this._model.screenshot); } this.updateVisibility(); } + getUrl(): string | undefined { + return this._model?.url; + } + async navigateToUrl(url: string): Promise { if (this._model) { this.group.pinEditor(this.input); // pin editor on navigation @@ -564,14 +585,44 @@ export class BrowserEditor extends EditorPane { // Update context keys for command enablement this._canGoBackContext.set(event.canGoBack); this._canGoForwardContext.set(event.canGoForward); + + // Update visibility (welcome screen, error, browser view) + this.updateVisibility(); + } + + /** + * Create the welcome container shown when no URL is loaded + */ + private createWelcomeContainer(): HTMLElement { + const container = $('.browser-welcome-container'); + const content = $('.browser-welcome-content'); + + const iconContainer = $('.browser-welcome-icon'); + iconContainer.appendChild(renderIcon(Codicon.globe)); + content.appendChild(iconContainer); + + const title = $('.browser-welcome-title'); + title.textContent = localize('browser.welcomeTitle', "Browser"); + content.appendChild(title); + + const subtitle = $('.browser-welcome-subtitle'); + subtitle.textContent = localize('browser.welcomeSubtitle', "Enter a URL above to get started."); + content.appendChild(subtitle); + + const tip = $('.browser-welcome-tip'); + tip.textContent = localize('browser.welcomeTip', "Tip: Use the Add Element to Chat feature to reference UI elements when asking Copilot for changes."); + content.appendChild(tip); + + container.appendChild(content); + return container; } private setBackgroundImage(buffer: VSBuffer | undefined): void { if (buffer) { const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`; - this._browserContainer.style.backgroundImage = `url('${dataUrl}')`; + this._placeholderScreenshot.style.backgroundImage = `url('${dataUrl}')`; } else { - this._browserContainer.style.backgroundImage = ''; + this._placeholderScreenshot.style.backgroundImage = ''; } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index d57048492af..6b8991df48f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -16,6 +16,8 @@ import { BrowserViewUri } from '../../../../platform/browserView/common/browserV import { IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; // Context key expression to check if browser editor is active const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); @@ -55,7 +57,7 @@ class GoBackAction extends Action2 { group: 'navigation', order: 1, }, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_BACK), + precondition: CONTEXT_BROWSER_CAN_GO_BACK, keybinding: { when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, @@ -87,9 +89,9 @@ class GoForwardAction extends Action2 { id: MenuId.BrowserNavigationToolbar, group: 'navigation', order: 2, - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD) + when: CONTEXT_BROWSER_CAN_GO_FORWARD }, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD), + precondition: CONTEXT_BROWSER_CAN_GO_FORWARD, keybinding: { when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, @@ -123,7 +125,7 @@ class ReloadAction extends Action2 { order: 3, }, keybinding: { - when: CONTEXT_BROWSER_FOCUSED, // Keybinding is only active when focus is within the browser editor + when: CONTEXT_BROWSER_FOCUSED, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over debug primary: KeyCode.F5, secondary: [KeyMod.CtrlCmd | KeyCode.KeyR], @@ -155,7 +157,16 @@ class AddElementToChatAction extends Action2 { group: 'actions', order: 1, when: ChatContextKeys.enabled - } + }, + keybinding: [{ + when: BROWSER_EDITOR_ACTIVE, + weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, + }, { + when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape + }] }); } @@ -174,14 +185,18 @@ class ToggleDevToolsAction extends Action2 { id: ToggleDevToolsAction.ID, title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), category: BrowserCategory, - icon: Codicon.tools, + icon: Codicon.console, f1: false, toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), menu: { id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 2, - when: BROWSER_EDITOR_ACTIVE + group: '1_developer', + order: 1, + }, + keybinding: { + when: BROWSER_EDITOR_ACTIVE, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.F12 } }); } @@ -193,6 +208,35 @@ class ToggleDevToolsAction extends Action2 { } } +class OpenInExternalBrowserAction extends Action2 { + static readonly ID = 'workbench.action.browser.openExternal'; + + constructor() { + super({ + id: OpenInExternalBrowserAction.ID, + title: localize2('browser.openExternalAction', 'Open in External Browser'), + category: BrowserCategory, + icon: Codicon.linkExternal, + f1: false, + menu: { + id: MenuId.BrowserActionsToolbar, + group: '2_export', + order: 1 + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + const url = browserEditor.getUrl(); + if (url) { + const openerService = accessor.get(IOpenerService); + await openerService.open(url, { openExternal: true }); + } + } + } +} + class ClearGlobalBrowserStorageAction extends Action2 { static readonly ID = 'workbench.action.browser.clearGlobalStorage'; @@ -205,7 +249,7 @@ class ClearGlobalBrowserStorageAction extends Action2 { f1: true, menu: { id: MenuId.BrowserActionsToolbar, - group: 'storage', + group: '3_settings', order: 1, when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) } @@ -230,7 +274,7 @@ class ClearWorkspaceBrowserStorageAction extends Action2 { f1: true, menu: { id: MenuId.BrowserActionsToolbar, - group: 'storage', + group: '3_settings', order: 2, when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) } @@ -243,6 +287,30 @@ class ClearWorkspaceBrowserStorageAction extends Action2 { } } +class OpenBrowserSettingsAction extends Action2 { + static readonly ID = 'workbench.action.browser.openSettings'; + + constructor() { + super({ + id: OpenBrowserSettingsAction.ID, + title: localize2('browser.openSettingsAction', 'Open Browser Settings'), + category: BrowserCategory, + icon: Codicon.settingsGear, + f1: false, + menu: { + id: MenuId.BrowserActionsToolbar, + group: '3_settings', + order: 3 + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const preferencesService = accessor.get(IPreferencesService); + await preferencesService.openSettings({ query: '@id:workbench.browser.*,chat.sendElementsToChat.*' }); + } +} + // Register actions registerAction2(OpenIntegratedBrowserAction); registerAction2(GoBackAction); @@ -250,5 +318,7 @@ registerAction2(GoForwardAction); registerAction2(ReloadAction); registerAction2(AddElementToChatAction); registerAction2(ToggleDevToolsAction); +registerAction2(OpenInExternalBrowserAction); registerAction2(ClearGlobalBrowserStorageAction); registerAction2(ClearWorkspaceBrowserStorageAction); +registerAction2(OpenBrowserSettingsAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 24c5be8b5d8..05c038bc151 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -47,12 +47,20 @@ margin: 0 2px 2px; overflow: hidden; position: relative; + outline: none !important; + } + + .browser-placeholder-screenshot { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; background-image: none; background-size: contain; background-repeat: no-repeat; filter: blur(0px); transition: opacity 300ms ease-out, filter 300ms ease-out; - outline: none !important; opacity: 1.0; &.blur { @@ -105,4 +113,62 @@ font-size: 12px; } } + + .browser-welcome-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--vscode-editor-background); + + .browser-welcome-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + } + + .browser-welcome-icon { + min-height: 48px; + + .codicon { + font-size: 40px; + } + } + + .browser-welcome-title { + font-size: 24px; + margin-top: 5px; + text-align: center; + line-height: normal; + padding: 0 8px; + } + + .browser-welcome-subtitle { + position: relative; + text-align: center; + max-width: 100%; + padding: 0 20px; + margin: 8px auto 0; + color: var(--vscode-foreground); + + p { + margin-top: 8px; + margin-bottom: 8px; + } + } + + .browser-welcome-tip { + color: var(--vscode-descriptionForeground); + text-align: center; + margin: 16px auto 0; + max-width: 400px; + padding: 0 12px; + font-style: italic; + } + } } diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 152b390c9a7..4cb1a09d8c5 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -107,6 +107,8 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi if (toolInvocation.toolSpecificData?.kind === 'terminal') { const terminalData = migrateLegacyTerminalToolSpecificData(toolInvocation.toolSpecificData); input = terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; + } else if (toolInvocation.toolSpecificData?.kind === 'subagent') { + input = toolInvocation.toolSpecificData.description ?? ''; } else { input = toolInvocation.toolSpecificData?.kind === 'extensions' ? JSON.stringify(toolInvocation.toolSpecificData.extensions) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts index ae11a7c88a2..a9c3343b1e3 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts @@ -248,7 +248,27 @@ class ConfigureLanguageModelsGroupAction extends Action2 { } } +class MigrateLanguageModelsGroupAction extends Action2 { + constructor() { + super({ + id: 'lm.migrateLanguageModelsProviderGroup', + title: localize('lm.migrateGroup', 'Migrate Language Models Group'), + }); + } + + async run(accessor: ServicesAccessor, languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { + const languageModelsService = accessor.get(ILanguageModelsService); + + if (!languageModelsProviderGroup) { + throw new Error('Language model group is required'); + } + + await languageModelsService.migrateLanguageModelsProviderGroup(languageModelsProviderGroup); + } +} + export function registerLanguageModelActions() { registerAction2(ManageLanguageModelAuthenticationAction); registerAction2(ConfigureLanguageModelsGroupAction); + registerAction2(MigrateLanguageModelsGroupAction); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 8accd14a179..9c0d2e6a662 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -26,6 +26,7 @@ import { IActionViewItemService } from '../../../../../platform/actions/browser/ import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; +import { AuxiliaryBarMaximizedContext } from '../../../../common/contextkeys.js'; //#region Actions and Menus @@ -80,7 +81,8 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.Stacked), - ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right) + ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right), + AuxiliaryBarMaximizedContext.negate() ) }); @@ -94,7 +96,8 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.Stacked), - ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left) + ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left), + AuxiliaryBarMaximizedContext.negate() ) }); @@ -108,7 +111,8 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.SideBySide), - ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right) + ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right), + AuxiliaryBarMaximizedContext.negate() ) }); @@ -122,7 +126,8 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.SideBySide), - ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left) + ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left), + AuxiliaryBarMaximizedContext.negate() ) }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 50f7d1bfc06..623f132ddf8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -29,7 +29,7 @@ import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { AgentSessionsPicker } from './agentSessionsPicker.js'; -import { ActiveEditorContext } from '../../../../common/contextkeys.js'; +import { ActiveEditorContext, AuxiliaryBarMaximizedContext } from '../../../../common/contextkeys.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; @@ -69,7 +69,6 @@ MenuRegistry.appendMenuItem(MenuId.ChatWelcomeContext, { when: ChatContextKeys.inChatEditor.negate() }); - export class SetAgentSessionsOrientationStackedAction extends Action2 { constructor() { @@ -77,7 +76,10 @@ export class SetAgentSessionsOrientationStackedAction extends Action2 { id: 'workbench.action.chat.setAgentSessionsOrientationStacked', title: localize2('chat.sessionsOrientation.stacked', "Stacked"), toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewSessionsOrientation}`, 'stacked'), - precondition: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewSessionsEnabled}`, true), + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewSessionsEnabled}`, true), + AuxiliaryBarMaximizedContext.negate() + ), menu: { id: agentSessionsOrientationSubmenu, group: 'navigation', @@ -100,7 +102,10 @@ export class SetAgentSessionsOrientationSideBySideAction extends Action2 { id: 'workbench.action.chat.setAgentSessionsOrientationSideBySide', title: localize2('chat.sessionsOrientation.sideBySide', "Side by Side"), toggled: ContextKeyExpr.notEquals(`config.${ChatConfiguration.ChatViewSessionsOrientation}`, 'stacked'), - precondition: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewSessionsEnabled}`, true), + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewSessionsEnabled}`, true), + AuxiliaryBarMaximizedContext.negate() + ), menu: { id: agentSessionsOrientationSubmenu, group: 'navigation', @@ -826,6 +831,7 @@ export class ShowAgentSessionsSidebar extends UpdateChatViewWidthAction { precondition: ContextKeyExpr.and( ChatContextKeys.enabled, ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.Stacked), + AuxiliaryBarMaximizedContext.negate() ), f1: true, category: CHAT_CATEGORY, @@ -849,6 +855,7 @@ export class HideAgentSessionsSidebar extends UpdateChatViewWidthAction { precondition: ContextKeyExpr.and( ChatContextKeys.enabled, ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.SideBySide), + AuxiliaryBarMaximizedContext.negate() ), f1: true, category: CHAT_CATEGORY, @@ -869,7 +876,10 @@ export class ToggleAgentSessionsSidebar extends Action2 { super({ id: ToggleAgentSessionsSidebar.ID, title: ToggleAgentSessionsSidebar.TITLE, - precondition: ChatContextKeys.enabled, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + AuxiliaryBarMaximizedContext.negate() + ), f1: true, category: CHAT_CATEGORY, }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index b579321fec1..73776e50163 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -359,19 +359,24 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide a start and end time to track + // Times: it is important to always provide timing information to track // unread/read state for example. // If somehow the provider does not provide any, fallback to last known - let startTime = session.timing.startTime; - let endTime = session.timing.endTime; - if (!startTime || !endTime) { + let created = session.timing.created; + let lastRequestStarted = session.timing.lastRequestStarted; + let lastRequestEnded = session.timing.lastRequestEnded; + if (!created || !lastRequestEnded) { const existing = this._sessions.get(session.resource); - if (!startTime && existing?.timing.startTime) { - startTime = existing.timing.startTime; + if (!created && existing?.timing.created) { + created = existing.timing.created; } - if (!endTime && existing?.timing.endTime) { - endTime = existing.timing.endTime; + if (!lastRequestEnded && existing?.timing.lastRequestEnded) { + lastRequestEnded = existing.timing.lastRequestEnded; + } + + if (!lastRequestStarted && existing?.timing.lastRequestStarted) { + lastRequestStarted = existing.timing.lastRequestStarted; } } @@ -386,7 +391,13 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status, archived: session.archived, - timing: { startTime, endTime, inProgressTime, finishedOrFailedTime }, + timing: { + created, + lastRequestStarted, + lastRequestEnded, + inProgressTime, + finishedOrFailedTime + }, changes: normalizedChanges, })); } @@ -454,7 +465,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private isRead(session: IInternalAgentSessionData): boolean { const readDate = this.sessionStates.get(session.resource)?.read; - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime); + return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); } private setRead(session: IInternalAgentSessionData, read: boolean): void { @@ -473,7 +484,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region Sessions Cache -interface ISerializedAgentSession extends Omit { +interface ISerializedAgentSession { readonly providerType: string; readonly providerLabel: string; @@ -492,7 +503,11 @@ interface ISerializedAgentSession extends Omit ({ + return cached.map((session): IInternalAgentSessionData => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -569,8 +585,10 @@ class AgentSessionsCache { archived: session.archived, timing: { - startTime: session.timing.startTime, - endTime: session.timing.endTime, + // Support loading both new and old cache formats + created: session.timing.created ?? session.timing.startTime ?? 0, + lastRequestStarted: session.timing.lastRequestStarted ?? session.timing.startTime, + lastRequestEnded: session.timing.lastRequestEnded ?? session.timing.endTime, }, changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index 75cd153a259..ba320e5e29f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -21,13 +21,7 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes session.setRead(true); // mark as read when opened - // Local chat sessions (chat history) should always open in the chat widget - if (isLocalAgentSessionItem(session)) { - await openSessionInChatWidget(accessor, session, openOptions); - return; - } - - // Check if Agent Session Projection is enabled for agent sessions + // Check if Agent Session Projection is enabled const agentSessionProjectionEnabled = configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; if (agentSessionProjectionEnabled) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index cd91ba6fbdb..ba5bfac455d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = { export function getSessionDescription(session: IAgentSession): string { const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined; - const timeAgo = fromNow(session.timing.endTime || session.timing.startTime); + const timeAgo = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part); return descriptionParts.join(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index f3d3e6e29cd..17c8d9f3a5a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -323,7 +323,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -826,7 +827,9 @@ export class AgentSessionsSorter implements ITreeSorter { } //Sort by end or start time (most recent first) - return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); + const timeA = sessionA.timing.lastRequestEnded ?? sessionA.timing.lastRequestStarted ?? sessionA.timing.created; + const timeB = sessionB.timing.lastRequestEnded ?? sessionB.timing.lastRequestStarted ?? sessionB.timing.created; + return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts index 0a71dc15ccc..8bdedfbde99 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts @@ -23,8 +23,8 @@ import { IAction } from '../../../../../base/common/actions.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; -const OPEN_CHAT_ACTION_ID = 'workbench.action.chat.open'; // Has the keybinding +const OPEN_CHAT_ACTION_ID = 'workbench.action.chat.open'; +const QUICK_CHAT_ACTION_ID = 'workbench.action.quickchat.toggle'; const QUICK_OPEN_ACTION_ID = 'workbench.action.quickOpenWithModes'; /** @@ -118,14 +118,38 @@ export class AgentsControlViewItem extends BaseActionViewItem { pill.classList.add('has-unread'); } pill.setAttribute('role', 'button'); - pill.setAttribute('aria-label', localize('openChat', "Open Chat")); + pill.setAttribute('aria-label', localize('openQuickChat', "Open Quick Chat")); pill.tabIndex = 0; this._container.appendChild(pill); - // Copilot icon (always shown) - const icon = $('span.agents-control-icon'); - reset(icon, renderIcon(Codicon.chatSparkle)); - pill.appendChild(icon); + // Left side indicator (status) + const leftIndicator = $('span.agents-control-status'); + if (hasActiveSessions) { + // Running indicator when there are active sessions + const runningIcon = $('span.agents-control-status-icon'); + reset(runningIcon, renderIcon(Codicon.sessionInProgress)); + leftIndicator.appendChild(runningIcon); + const runningCount = $('span.agents-control-status-text'); + runningCount.textContent = String(activeSessions.length); + leftIndicator.appendChild(runningCount); + } else if (hasUnreadSessions) { + // Unread indicator when there are unread sessions + const unreadIcon = $('span.agents-control-status-icon'); + reset(unreadIcon, renderIcon(Codicon.circleFilled)); + leftIndicator.appendChild(unreadIcon); + const unreadCount = $('span.agents-control-status-text'); + unreadCount.textContent = String(unreadSessions.length); + leftIndicator.appendChild(unreadCount); + } else { + // Keyboard shortcut when idle (show open chat keybinding) + const kb = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); + if (kb) { + const kbLabel = $('span.agents-control-keybinding'); + kbLabel.textContent = kb; + leftIndicator.appendChild(kbLabel); + } + } + pill.appendChild(leftIndicator); // Show workspace name (centered) const label = $('span.agents-control-label'); @@ -133,48 +157,24 @@ export class AgentsControlViewItem extends BaseActionViewItem { label.textContent = workspaceName; pill.appendChild(label); - // Right side indicator - const rightIndicator = $('span.agents-control-status'); - if (hasActiveSessions) { - // Running indicator when there are active sessions - const runningIcon = $('span.agents-control-status-icon'); - reset(runningIcon, renderIcon(Codicon.sessionInProgress)); - rightIndicator.appendChild(runningIcon); - const runningCount = $('span.agents-control-status-text'); - runningCount.textContent = String(activeSessions.length); - rightIndicator.appendChild(runningCount); - } else if (hasUnreadSessions) { - // Unread indicator when there are unread sessions - const unreadIcon = $('span.agents-control-status-icon'); - reset(unreadIcon, renderIcon(Codicon.circleFilled)); - rightIndicator.appendChild(unreadIcon); - const unreadCount = $('span.agents-control-status-text'); - unreadCount.textContent = String(unreadSessions.length); - rightIndicator.appendChild(unreadCount); - } else { - // Keyboard shortcut when idle (show open chat keybinding) - const kb = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); - if (kb) { - const kbLabel = $('span.agents-control-keybinding'); - kbLabel.textContent = kb; - rightIndicator.appendChild(kbLabel); - } - } - pill.appendChild(rightIndicator); + // Send icon (right side) + const sendIcon = $('span.agents-control-send'); + reset(sendIcon, renderIcon(Codicon.send)); + pill.appendChild(sendIcon); // Setup hover with keyboard shortcut const hoverDelegate = getDefaultHoverDelegate('mouse'); - const kbForTooltip = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); + const kbForTooltip = this.keybindingService.lookupKeybinding(QUICK_CHAT_ACTION_ID)?.getLabel(); const tooltip = kbForTooltip - ? localize('askTooltip', "Open Chat ({0})", kbForTooltip) - : localize('askTooltip2', "Open Chat"); + ? localize('askTooltip', "Open Quick Chat ({0})", kbForTooltip) + : localize('askTooltip2', "Open Quick Chat"); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, tooltip)); - // Click handler - open chat + // Click handler - open quick chat disposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); - this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + this.commandService.executeCommand(QUICK_CHAT_ACTION_ID); })); // Keyboard handler @@ -182,7 +182,7 @@ export class AgentsControlViewItem extends BaseActionViewItem { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + this.commandService.executeCommand(QUICK_CHAT_ACTION_ID); } })); @@ -198,18 +198,13 @@ export class AgentsControlViewItem extends BaseActionViewItem { const pill = $('div.agents-control-pill.session-mode'); this._container.appendChild(pill); - // Copilot icon - const iconContainer = $('span.agents-control-icon'); - reset(iconContainer, renderIcon(Codicon.chatSparkle)); - pill.appendChild(iconContainer); - - // Session title + // Session title (left/center) const titleLabel = $('span.agents-control-title'); const session = this.focusViewService.activeSession; titleLabel.textContent = session?.label ?? localize('agentSessionProjection', "Agent Session Projection"); pill.appendChild(titleLabel); - // Close button + // Close button (right side) const closeButton = $('span.agents-control-close'); closeButton.classList.add('codicon', 'codicon-close'); closeButton.setAttribute('role', 'button'); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts index cfcd09839dd..3225c5f6ebd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts @@ -23,6 +23,7 @@ import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; +import { IChatEditingService, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; //#region Configuration @@ -31,11 +32,12 @@ import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; * Only sessions from these providers will trigger focus view. * * Configuration: - * - AgentSessionProviders.Local: Local chat sessions (disabled) + * - AgentSessionProviders.Local: Local chat sessions (enabled) * - AgentSessionProviders.Background: Background CLI agents (enabled) * - AgentSessionProviders.Cloud: Cloud agents (enabled) */ const AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS: Set = new Set([ + AgentSessionProviders.Local, AgentSessionProviders.Background, AgentSessionProviders.Cloud, ]); @@ -118,6 +120,7 @@ export class FocusViewService extends Disposable implements IFocusViewService { @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @ICommandService private readonly commandService: ICommandService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, ) { super(); @@ -203,38 +206,58 @@ export class FocusViewService extends Disposable implements IFocusViewService { return; } - if (!this._isActive) { - // First time entering focus view - save the current working set as our "non-focus-view" backup - this._nonFocusViewWorkingSet = this.editorGroupsService.saveWorkingSet('focus-view-backup'); - } else if (this._activeSession) { - // Already in focus view, switching sessions - save the current session's working set - const previousSessionKey = this._activeSession.resource.toString(); - const previousWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${previousSessionKey}`); - this._sessionWorkingSets.set(previousSessionKey, previousWorkingSet); + // For local sessions, check if there are pending edits to show + // If there's nothing to focus, just open the chat without entering focus view mode + let hasUndecidedChanges = true; + if (session.providerType === AgentSessionProviders.Local) { + const editingSession = this.chatEditingService.getEditingSession(session.resource); + hasUndecidedChanges = editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified) ?? false; + if (!hasUndecidedChanges) { + this.logService.trace('[FocusView] Local session has no undecided changes, opening chat without focus view'); + } } - // Always open session files to ensure they're displayed - await this._openSessionFiles(session); + // Only enter focus view mode if there are changes to show + if (hasUndecidedChanges) { + if (!this._isActive) { + // First time entering focus view - save the current working set as our "non-focus-view" backup + this._nonFocusViewWorkingSet = this.editorGroupsService.saveWorkingSet('focus-view-backup'); + } else if (this._activeSession) { + // Already in focus view, switching sessions - save the current session's working set + const previousSessionKey = this._activeSession.resource.toString(); + const previousWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${previousSessionKey}`); + this._sessionWorkingSets.set(previousSessionKey, previousWorkingSet); + } - // Set active state - const wasActive = this._isActive; - this._isActive = true; - this._activeSession = session; - this._inFocusViewModeContextKey.set(true); - this.layoutService.mainContainer.classList.add('focus-view-active'); - if (!wasActive) { - this._onDidChangeFocusViewMode.fire(true); + // Always open session files to ensure they're displayed + await this._openSessionFiles(session); + + // Set active state + const wasActive = this._isActive; + this._isActive = true; + this._activeSession = session; + this._inFocusViewModeContextKey.set(true); + this.layoutService.mainContainer.classList.add('focus-view-active'); + if (!wasActive) { + this._onDidChangeFocusViewMode.fire(true); + } + // Always fire session change event (for title updates when switching sessions) + this._onDidChangeActiveSession.fire(session); } - // Always fire session change event (for title updates when switching sessions) - this._onDidChangeActiveSession.fire(session); - // Open the session in the chat panel + // Open the session in the chat panel (always, even without changes) session.setRead(true); await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget, { title: { preferred: session.label }, revealIfOpened: true }); + + // For local sessions with changes, also pop open the edit session's changes view + // Must be after openSession so the editing session context is available + if (session.providerType === AgentSessionProviders.Local && hasUndecidedChanges) { + await this.commandService.executeCommand('chatEditing.viewChanges'); + } } async exitFocusView(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index 499cc8015c7..8b2b3947efb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -12,7 +12,7 @@ import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IChatModel } from '../../common/model/chatModel.js'; -import { IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; +import { convertLegacyChatSessionTiming, IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -115,7 +115,7 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess description, status: model ? this.modelToStatus(model) : this.chatResponseStateToStatus(chat.lastResponseState), iconPath: Codicon.chatSparkle, - timing: chat.timing, + timing: convertLegacyChatSessionTiming(chat.timing), changes: chat.stats ? { insertions: chat.stats.added, deletions: chat.stats.removed, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css index bea8ba912b9..ebae07a1903 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css @@ -4,22 +4,61 @@ *--------------------------------------------------------------------------------------------*/ /* ======================================== -Focus View Mode - Blue glow border around entire workbench +Focus View Mode - Tab styling to match agents control ======================================== */ -.monaco-workbench.focus-view-active::after { +/* Style all tabs with the same background as the agents control */ +.monaco-workbench.focus-view-active .part.editor > .content .editor-group-container > .title .tabs-container > .tab { + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent) !important; +} + +/* Active tab gets slightly stronger tint */ +.monaco-workbench.focus-view-active .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent) !important; +} + +.hc-black .monaco-workbench.focus-view-active .part.editor > .content .editor-group-container > .title .tabs-container > .tab, +.hc-light .monaco-workbench.focus-view-active .part.editor > .content .editor-group-container > .title .tabs-container > .tab { + background-color: transparent !important; + border: 1px solid var(--vscode-contrastBorder); +} + +/* Border around entire editor area using pseudo-element overlay */ +.monaco-workbench.focus-view-active .part.editor { + position: relative; +} + +@keyframes focus-view-glow-pulse { + 0%, 100% { + box-shadow: + 0 0 8px 2px color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent), + 0 0 20px 4px color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent), + inset 0 0 15px 2px color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); + } + 50% { + box-shadow: + 0 0 15px 4px color-mix(in srgb, var(--vscode-progressBar-background) 70%, transparent), + 0 0 35px 8px color-mix(in srgb, var(--vscode-progressBar-background) 35%, transparent), + inset 0 0 25px 4px color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent); + } +} + +.monaco-workbench.focus-view-active .part.editor::after { content: ''; position: absolute; inset: 0; pointer-events: none; - z-index: 10000; - box-shadow: inset 0 0 0 3px rgba(0, 120, 212, 0.8), inset 0 0 30px rgba(0, 120, 212, 0.4); - transition: box-shadow 0.2s ease-in-out; + z-index: 1000; + border: 2px solid var(--vscode-progressBar-background); + border-radius: 4px; + animation: focus-view-glow-pulse 2s ease-in-out infinite; } -.hc-black .monaco-workbench.focus-view-active::after, -.hc-light .monaco-workbench.focus-view-active::after { - box-shadow: inset 0 0 0 2px var(--vscode-contrastBorder); +.hc-black .monaco-workbench.focus-view-active .part.editor::after, +.hc-light .monaco-workbench.focus-view-active .part.editor::after { + border-color: var(--vscode-contrastBorder); + animation: none; + box-shadow: none; } /* ======================================== @@ -85,18 +124,17 @@ Agents Control - Titlebar control /* Active state - has running sessions */ .agents-control-pill.chat-input-mode.has-active { - background-color: rgba(0, 120, 212, 0.15); - border: 1px solid rgba(0, 120, 212, 0.5); + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); } .agents-control-pill.chat-input-mode.has-active:hover { - background-color: rgba(0, 120, 212, 0.25); - border-color: rgba(0, 120, 212, 0.7); + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent); + border-color: color-mix(in srgb, var(--vscode-progressBar-background) 70%, transparent); } -.agents-control-pill.chat-input-mode.has-active .agents-control-icon, .agents-control-pill.chat-input-mode.has-active .agents-control-label { - color: var(--vscode-textLink-foreground); + color: var(--vscode-progressBar-background); opacity: 1; } @@ -107,27 +145,14 @@ Agents Control - Titlebar control /* Session mode (viewing a session) */ .agents-control-pill.session-mode { - background-color: rgba(0, 120, 212, 0.15); - border: 1px solid rgba(0, 120, 212, 0.5); + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); padding: 0 12px; } .agents-control-pill.session-mode:hover { - background-color: rgba(0, 120, 212, 0.25); - border-color: rgba(0, 120, 212, 0.7); -} - -/* Icon */ -.agents-control-icon { - display: flex; - align-items: center; - color: var(--vscode-foreground); - opacity: 0.7; -} - -.agents-control-pill.session-mode .agents-control-icon { - color: var(--vscode-textLink-foreground); - opacity: 1; + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent); + border-color: color-mix(in srgb, var(--vscode-progressBar-background) 70%, transparent); } /* Label (workspace name, centered) */ @@ -141,10 +166,8 @@ Agents Control - Titlebar control text-overflow: ellipsis; } -/* Right side status indicator */ +/* Left side status indicator */ .agents-control-status { - position: absolute; - right: 8px; display: flex; align-items: center; gap: 4px; @@ -152,7 +175,7 @@ Agents Control - Titlebar control } .agents-control-pill.has-active .agents-control-status { - color: var(--vscode-textLink-foreground); + color: var(--vscode-progressBar-background); } .agents-control-status-icon { @@ -170,6 +193,19 @@ Agents Control - Titlebar control opacity: 0.7; } +/* Send icon (right side) */ +.agents-control-send { + display: flex; + align-items: center; + color: var(--vscode-foreground); + opacity: 0.7; +} + +.agents-control-pill.has-active .agents-control-send { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + /* Session title */ .agents-control-title { flex: 1; @@ -180,7 +216,7 @@ Agents Control - Titlebar control white-space: nowrap; } -/* Close button */ +/* Close button (right side in session mode) */ .agents-control-close { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 6ef8420978a..bc89fbfdf76 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -54,7 +54,7 @@ import { ILanguageModelToolsConfirmationService } from '../common/tools/language import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; @@ -136,6 +136,7 @@ import { ChatWidgetService } from './widget/chatWidgetService.js'; import { ILanguageModelsConfigurationService } from '../common/languageModelsConfiguration.js'; import { ChatWindowNotifier } from './chatWindowNotifier.js'; import { ChatRepoInfoContribution } from './chatRepoInfo.js'; +import { VALID_SKILL_PATH_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -718,11 +719,37 @@ configurationRegistry.registerConfiguration({ [PromptsConfig.USE_AGENT_SKILLS]: { type: 'boolean', title: nls.localize('chat.useAgentSkills.title', "Use Agent skills",), - markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from `.github/skills`, `~/.copilot/skills`, `.claude/skills`, and `~/.claude/skills`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), - default: false, + markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from the folders configured in `#chat.agentSkillsLocations#`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), + default: true, restricted: true, disallowConfigurationDefault: true, - tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.SKILLS_LOCATION_KEY]: { + type: 'object', + title: nls.localize('chat.agentSkillsLocations.title', "Agent Skills Locations",), + markdownDescription: nls.localize('chat.agentSkillsLocations.description', "Specify where agent skills are located. Each path should contain skill subfolders with SKILL.md files (e.g., my-skills/skillA/SKILL.md → add my-skills).\n\n**Supported path types:**\n- Workspace paths: `my-skills`, `./my-skills`, `../shared-skills`\n- User home paths: `~/.copilot/skills`, `~/.claude/skills`",), + default: { + ...DEFAULT_SKILL_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_SKILL_PATH_PATTERN, + patternErrorMessage: nls.localize('chat.agentSkillsLocations.invalidPath', "Skill location paths must either be relative paths or start with '~' for user home directory."), + }, + restricted: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, + }, + { + [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, + 'my-skills': true, + '../shared-skills': true, + '~/.custom/skills': true, + }, + ], }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css index 1033ada08b1..a29b8276312 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css @@ -6,7 +6,7 @@ .chat-editor-overlay-widget { padding: 2px 4px; color: var(--vscode-foreground); - background-color: var(--vscode-editor-background); + background-color: var(--vscode-editorWidget-background); border-radius: 6px; border: 1px solid var(--vscode-contrastBorder); display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css index 5e4b3de1ebc..402bd4b6b0b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css @@ -19,7 +19,7 @@ .chat-diff-change-content-widget .monaco-action-bar { padding: 4px 4px; border-radius: 6px; - background-color: var(--vscode-editor-background); + background-color: var(--vscode-editorWidget-background); color: var(--vscode-foreground); border: 1px solid var(--vscode-contrastBorder); overflow: hidden; diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index 76f0b41e3ca..f9576058087 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -10,10 +10,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../common/chatSessionsService.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; @@ -33,17 +30,16 @@ export interface IChatSessionPickerDelegate { * These options are provided by the relevant ChatSession Provider */ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewItem { - currentOption: IChatSessionProviderOptionItem | undefined; + protected currentOption: IChatSessionProviderOptionItem | undefined; + protected container: HTMLElement | undefined; + constructor( action: IAction, initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, - private readonly delegate: IChatSessionPickerDelegate, + protected readonly delegate: IChatSessionPickerDelegate, @IActionWidgetService actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, - @ICommandService commandService: ICommandService, - @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @IKeybindingService keybindingService: IKeybindingService, - @ITelemetryService telemetryService: ITelemetryService, ) { const { group, item } = initialState; const actionWithLabel: IAction = { @@ -55,44 +51,7 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI const sessionPickerActionWidgetOptions: Omit = { actionProvider: { - getActions: () => { - // if locked, show the current option only - const currentOption = this.delegate.getCurrentOption(); - if (currentOption?.locked) { - return [{ - id: currentOption.id, - enabled: false, - icon: currentOption.icon, - checked: true, - class: undefined, - description: undefined, - tooltip: currentOption.description ?? currentOption.name, - label: currentOption.name, - run: () => { } - } satisfies IActionWidgetDropdownAction]; - } else { - const group = this.delegate.getOptionGroup(); - if (!group) { - return []; - } - return group.items.map(optionItem => { - const isCurrent = optionItem.id === this.delegate.getCurrentOption()?.id; - return { - id: optionItem.id, - enabled: true, - icon: optionItem.icon, - checked: isCurrent, - class: undefined, - description: undefined, - tooltip: optionItem.description ?? optionItem.name, - label: optionItem.name, - run: () => { - this.delegate.setOption(optionItem); - } - } satisfies IActionWidgetDropdownAction; - }); - } - } + getActions: () => this.getDropdownActions() }, actionBarActionProvider: undefined, }; @@ -105,24 +64,103 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI if (this.element) { this.renderLabel(this.element); } + this.updateEnabled(); })); } + + /** + * Returns the actions to show in the dropdown. Can be overridden by subclasses. + */ + protected getDropdownActions(): IActionWidgetDropdownAction[] { + // if locked, show the current option only + const currentOption = this.delegate.getCurrentOption(); + if (currentOption?.locked) { + return [this.createLockedOptionAction(currentOption)]; + } + + const group = this.delegate.getOptionGroup(); + if (!group) { + return []; + } + + return group.items.map(optionItem => { + const isCurrent = optionItem.id === currentOption?.id; + return { + id: optionItem.id, + enabled: !optionItem.locked, + icon: optionItem.icon, + checked: isCurrent, + class: undefined, + description: undefined, + tooltip: optionItem.description ?? optionItem.name, + label: optionItem.name, + run: () => { + this.delegate.setOption(optionItem); + } + } satisfies IActionWidgetDropdownAction; + }); + } + + /** + * Creates a disabled action for a locked option. + */ + protected createLockedOptionAction(option: IChatSessionProviderOptionItem): IActionWidgetDropdownAction { + return { + id: option.id, + enabled: false, + icon: option.icon, + checked: true, + class: undefined, + description: undefined, + tooltip: option.description ?? option.name, + label: option.name, + run: () => { } + }; + } + protected override renderLabel(element: HTMLElement): IDisposable | null { const domChildren = []; element.classList.add('chat-session-option-picker'); + if (this.currentOption?.icon) { domChildren.push(renderIcon(this.currentOption.icon)); } domChildren.push(dom.$('span.chat-session-option-label', undefined, this.currentOption?.name ?? localize('chat.sessionPicker.label', "Pick Option"))); + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); return null; } override render(container: HTMLElement): void { + this.container = container; super.render(container); - container.classList.add('chat-sessionPicker-item'); + container.classList.add(this.getContainerClass()); + + // Set initial locked state on container + if (this.currentOption?.locked) { + container.classList.add('locked'); + } } + /** + * Returns the CSS class to add to the container. Can be overridden by subclasses. + */ + protected getContainerClass(): string { + return 'chat-sessionPicker-item'; + } + + protected override updateEnabled(): void { + const originalEnabled = this.action.enabled; + if (this.currentOption?.locked) { + this.action.enabled = false; + } + super.updateEnabled(); + this.action.enabled = originalEnabled; + if (this.container) { + this.container.classList.toggle('locked', !!this.currentOption?.locked); + } + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css index 57dc5afbaeb..05791a7f530 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css @@ -37,3 +37,17 @@ margin-left: 2px; } } + +/* Locked state styling - matches disabled action item styling */ +.monaco-action-bar .action-item.locked .chat-session-option-picker { + color: var(--vscode-disabledForeground); + cursor: default; +} + +.monaco-action-bar .action-item.locked .chat-session-option-picker .codicon { + color: var(--vscode-disabledForeground); +} + +.monaco-action-bar .action-item.locked { + pointer-events: none; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts index 8a7d7742419..16ef8fb0736 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts @@ -9,18 +9,16 @@ import { CancellationTokenSource } from '../../../../../base/common/cancellation import { Delayer } from '../../../../../base/common/async.js'; import * as dom from '../../../../../base/browser/dom.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { IActionWidgetDropdownAction, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IActionWidgetDropdownAction } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../common/chatSessionsService.js'; import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { renderLabelWithIcons, renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../../nls.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { IChatSessionPickerDelegate } from './chatSessionPickerActionItem.js'; +import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from './chatSessionPickerActionItem.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; interface ISearchableOptionQuickPickItem extends IQuickPickItem { @@ -36,102 +34,69 @@ function isSearchableOptionQuickPickItem(item: IQuickPickItem | undefined): item * Used when an option group has `searchable: true` (e.g., repository selection). * Shows an inline dropdown with items + "See more..." option that opens a searchable QuickPick. */ -export class SearchableOptionPickerActionItem extends ActionWidgetDropdownActionViewItem { - private currentOption: IChatSessionProviderOptionItem | undefined; +export class SearchableOptionPickerActionItem extends ChatSessionPickerActionItem { private static readonly SEE_MORE_ID = '__see_more__'; constructor( action: IAction, initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, - private readonly delegate: IChatSessionPickerDelegate, + delegate: IChatSessionPickerDelegate, @IActionWidgetService actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService keybindingService: IKeybindingService, @IQuickInputService private readonly quickInputService: IQuickInputService, @ILogService private readonly logService: ILogService, ) { - const { group, item } = initialState; - const actionWithLabel: IAction = { - ...action, - label: item?.name || group.name, - tooltip: item?.description ?? group.description ?? group.name, - run: () => { } - }; + super(action, initialState, delegate, actionWidgetService, contextKeyService, keybindingService); + } - const searchablePickerOptions: Omit = { - actionProvider: { - getActions: () => { - // If locked, show the current option only - const currentOption = this.delegate.getCurrentOption(); - if (currentOption?.locked) { - return [{ - id: currentOption.id, - enabled: false, - icon: currentOption.icon, - checked: true, - class: undefined, - description: undefined, - tooltip: currentOption.description ?? currentOption.name, - label: currentOption.name, - run: () => { } - } satisfies IActionWidgetDropdownAction]; - } + protected override getDropdownActions(): IActionWidgetDropdownAction[] { + // If locked, show the current option only + const currentOption = this.delegate.getCurrentOption(); + if (currentOption?.locked) { + return [this.createLockedOptionAction(currentOption)]; + } - const actions: IActionWidgetDropdownAction[] = []; - const optionGroup = this.delegate.getOptionGroup(); - if (!optionGroup) { - return []; - } + const optionGroup = this.delegate.getOptionGroup(); + if (!optionGroup) { + return []; + } - // Build actions from items - optionGroup.items.map(optionItem => { - const isCurrent = optionItem.id === currentOption?.id; - actions.push({ - id: optionItem.id, - enabled: !optionItem.locked, - icon: optionItem.icon, - checked: isCurrent, - class: undefined, - description: undefined, - tooltip: optionItem.description ?? optionItem.name, - label: optionItem.name, - run: () => { - this.delegate.setOption(optionItem); - } - }); - }); - - // Add "See more..." action if onSearch is available - if (optionGroup.onSearch) { - actions.push({ - id: SearchableOptionPickerActionItem.SEE_MORE_ID, - enabled: true, - checked: false, - class: 'searchable-picker-see-more', - description: undefined, - tooltip: localize('seeMore.tooltip', "Search for more options"), - label: localize('seeMore', "See more..."), - run: () => { - this.showSearchableQuickPick(optionGroup); - } - } satisfies IActionWidgetDropdownAction); - } - - return actions; + // Build actions from items + const actions: IActionWidgetDropdownAction[] = optionGroup.items.map(optionItem => { + const isCurrent = optionItem.id === currentOption?.id; + return { + id: optionItem.id, + enabled: !optionItem.locked, + icon: optionItem.icon, + checked: isCurrent, + class: undefined, + description: undefined, + tooltip: optionItem.description ?? optionItem.name, + label: optionItem.name, + run: () => { + this.delegate.setOption(optionItem); } - }, - actionBarActionProvider: undefined, - }; + }; + }); - super(actionWithLabel, searchablePickerOptions, actionWidgetService, keybindingService, contextKeyService); - this.currentOption = item; + // Add "See more..." action if onSearch is available + if (optionGroup.onSearch) { + actions.push({ + id: SearchableOptionPickerActionItem.SEE_MORE_ID, + enabled: true, + checked: false, + class: 'searchable-picker-see-more', + description: undefined, + tooltip: localize('seeMore.tooltip', "Search for more options"), + label: localize('seeMore', "See more..."), + run: () => { + this.showSearchableQuickPick(optionGroup); + } + } satisfies IActionWidgetDropdownAction); + } - this._register(this.delegate.onDidChangeOption(newOption => { - this.currentOption = newOption; - if (this.element) { - this.renderLabel(this.element); - } - })); + return actions; } protected override renderLabel(element: HTMLElement): IDisposable | null { @@ -139,6 +104,7 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction const optionGroup = this.delegate.getOptionGroup(); element.classList.add('chat-session-option-picker'); + if (optionGroup?.icon) { domChildren.push(renderIcon(optionGroup.icon)); } @@ -147,22 +113,15 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction const label = this.currentOption?.name ?? optionGroup?.name ?? localize('selectOption', "Select..."); domChildren.push(dom.$('span.chat-session-option-label', undefined, label)); - // Chevron domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); - // Locked indicator - if (this.currentOption?.locked) { - domChildren.push(renderIcon(Codicon.lock)); - } - dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); return null; } - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('chat-searchable-option-picker-item'); + protected override getContainerClass(): string { + return 'chat-searchable-option-picker-item'; } /** diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index d1b74bbdb7e..960490245e5 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -34,7 +34,7 @@ export async function askForPromptSourceFolder( const workspaceService = accessor.get(IWorkspaceContextService); // get prompts source folders based on the prompt type - const folders = promptsService.getSourceFolders(type); + const folders = await promptsService.getSourceFolders(type); // if no source folders found, show 'learn more' dialog // note! this is a temporary solution and must be replaced with a dialog to select diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index f99947bb595..71ce3a1a959 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -15,7 +15,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; -import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; +import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID } from '../newPromptFileActions.js'; import { IKeyMods, IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator } from '../../../../../../platform/quickinput/common/quickInput.js'; import { askForPromptFileName } from './askForPromptName.js'; @@ -90,6 +90,12 @@ function newHelpButton(type: PromptsType): IQuickInputButton & { helpURI: URI } helpURI: URI.parse(AGENT_DOCUMENTATION_URL), iconClass }; + case PromptsType.skill: + return { + tooltip: localize('help.skill', "Show help on skill files"), + helpURI: URI.parse(SKILL_DOCUMENTATION_URL), + iconClass + }; } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 1433c490cde..1b7672615c5 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -406,7 +406,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters); } else { // Create a new tool invocation (no streaming phase) - toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.fromSubAgent, dto.parameters); + toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.subAgentInvocationId, dto.parameters); this._chatService.appendProgress(request, toolInvocation); } @@ -601,12 +601,18 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } + // Don't create a streaming invocation for tools that don't implement handleToolStream. + // These tools will have their invocation created directly in invokeToolInternal. + if (!toolEntry.impl?.handleToolStream) { + return undefined; + } + // Create the invocation in streaming state const invocation = ChatToolInvocation.createStreaming({ toolCallId: options.toolCallId, toolId: options.toolId, toolData: toolEntry.data, - fromSubAgent: options.fromSubAgent, + subagentInvocationId: options.subagentInvocationId, chatRequestId: options.chatRequestId, }); @@ -618,9 +624,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const model = this._chatService.getSession(options.sessionResource); if (model) { // Find the request by chatRequestId if available, otherwise use the last request - const request = options.chatRequestId + const request = (options.chatRequestId ? model.getRequests().find(r => r.id === options.chatRequestId) - : model.getRequests().at(-1); + : undefined) ?? model.getRequests().at(-1); if (request) { this._chatService.appendProgress(request, invocation); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts new file mode 100644 index 00000000000..1d4d8b9095d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { IChatContentPartRenderContext } from './chatContentParts.js'; + +/** + * A collapsible content part that displays markdown content. + * The title is shown in the collapsed state, and the full content is shown when expanded. + */ +export class ChatCollapsibleMarkdownContentPart extends ChatCollapsibleContentPart { + + private contentElement: HTMLElement | undefined; + + constructor( + title: string, + private readonly markdownContent: string, + context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + @IHoverService hoverService: IHoverService, + ) { + super(title, context, undefined, hoverService); + this.icon = Codicon.check; + } + + protected override initContent(): HTMLElement { + const wrapper = $('.chat-collapsible-markdown-content.chat-used-context-list'); + + if (this.markdownContent) { + this.contentElement = $('.chat-collapsible-markdown-body'); + const rendered = this._register(this.chatContentMarkdownRenderer.render(new MarkdownString(this.markdownContent), { + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + })); + this.contentElement.appendChild(rendered.element); + wrapper.appendChild(this.contentElement); + } + + return wrapper; + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + // This part is embedded in the subagent part, not rendered directly + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts index 394a981cbec..a163fb84ca5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -53,20 +53,51 @@ type ContentRefData = readonly range?: IRange; }; +type InlineAnchorWidgetMetadata = { + vscodeLinkType: string; + linkText?: string; +}; + export function renderFileWidgets(element: HTMLElement, instantiationService: IInstantiationService, chatMarkdownAnchorService: IChatMarkdownAnchorService, disposables: DisposableStore) { // eslint-disable-next-line no-restricted-syntax const links = element.querySelectorAll('a'); links.forEach(a => { // Empty link text -> render file widget - if (!a.textContent?.trim()) { - const href = a.getAttribute('data-href'); - const uri = href ? URI.parse(href) : undefined; - if (uri?.scheme) { - const widget = instantiationService.createInstance(InlineAnchorWidget, a, { kind: 'inlineReference', inlineReference: uri }); - disposables.add(chatMarkdownAnchorService.register(widget)); - disposables.add(widget); + // Also support metadata format: [linkText](file:///...uri?vscodeLinkType=...) + const linkText = a.textContent?.trim(); + let shouldRenderWidget = false; + let metadata: InlineAnchorWidgetMetadata | undefined; + + const href = a.getAttribute('data-href'); + let uri: URI | undefined; + if (href) { + try { + uri = URI.parse(href); + } catch { + // Invalid URI, skip rendering widget } } + + if (!linkText) { + shouldRenderWidget = true; + } else if (uri) { + // Check for vscodeLinkType in query parameters + const searchParams = new URLSearchParams(uri.query); + const vscodeLinkType = searchParams.get('vscodeLinkType'); + if (vscodeLinkType) { + metadata = { + vscodeLinkType, + linkText + }; + shouldRenderWidget = true; + } + } + + if (shouldRenderWidget && uri?.scheme) { + const widget = instantiationService.createInstance(InlineAnchorWidget, a, { kind: 'inlineReference', inlineReference: uri }, metadata); + disposables.add(chatMarkdownAnchorService.register(widget)); + disposables.add(widget); + } }); } @@ -81,6 +112,7 @@ export class InlineAnchorWidget extends Disposable { constructor( private readonly element: HTMLAnchorElement | HTMLElement, public readonly inlineReference: IChatContentInlineReference, + private readonly metadata: InlineAnchorWidgetMetadata | undefined, @IContextKeyService originalContextKeyService: IContextKeyService, @IContextMenuService contextMenuService: IContextMenuService, @IFileService fileService: IFileService, @@ -126,7 +158,8 @@ export class InlineAnchorWidget extends Disposable { } else { location = this.data; - const filePathLabel = labelService.getUriBasenameLabel(location.uri); + const filePathLabel = this.metadata?.linkText ?? labelService.getUriBasenameLabel(location.uri); + if (location.range && this.data.kind !== 'symbol') { const suffix = location.range.startLineNumber === location.range.endLineNumber ? `:${location.range.startLineNumber}` diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 6e26df575f1..8e571e80b46 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -278,7 +278,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP return ref.object.element; } else { const requestId = isRequestVM(element) ? element.id : element.requestId; - const ref = this.renderCodeBlockPill(element.sessionResource, requestId, inUndoStop, codeBlockInfo.codemapperUri, this.markdown.fromSubagent); + const ref = this.renderCodeBlockPill(element.sessionResource, requestId, inUndoStop, codeBlockInfo.codemapperUri); if (isResponseVM(codeBlockInfo.element)) { // TODO@joyceerhl: remove this code when we change the codeblockUri API to make the URI available synchronously this.codeBlockModelCollection.update(codeBlockInfo.element.sessionResource, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { @@ -382,10 +382,10 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } - private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined, fromSubagent?: boolean): IDisposableReference { + private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined): IDisposableReference { const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock, sessionResource, requestId, inUndoStop); if (codemapperUri) { - codeBlock.render(codemapperUri, fromSubagent); + codeBlock.render(codemapperUri); } return { object: codeBlock, @@ -551,9 +551,7 @@ export class CollapsedCodeBlock extends Disposable { * @param uri URI of the file on-disk being changed * @param isStreaming Whether the edit has completed (at the time of this being rendered) */ - render(uri: URI, fromSubagent?: boolean): void { - this.pillElement.classList.toggle('from-sub-agent', !!fromSubagent); - + render(uri: URI): void { this.progressStore.clear(); this._uri = uri; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index fd0dafe3b60..cd6169b793e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -245,7 +245,7 @@ export class ChatMarkdownDecorationsRenderer { return; } - const inlineAnchor = store.add(this.instantiationService.createInstance(InlineAnchorWidget, a, data)); + const inlineAnchor = store.add(this.instantiationService.createInstance(InlineAnchorWidget, a, data, undefined)); store.add(this.chatMarkdownAnchorService.register(inlineAnchor)); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts new file mode 100644 index 00000000000..ed335fca9d7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { rcut } from '../../../../../../base/common/strings.js'; +import { localize } from '../../../../../../nls.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { ChatCollapsibleMarkdownContentPart } from './chatCollapsibleMarkdownContentPart.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { IRunSubagentToolInputParams, RunSubagentTool } from '../../../common/tools/builtinTools/runSubagentTool.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { createThinkingIcon, getToolInvocationIcon } from './chatThinkingContentPart.js'; +import './media/chatSubagentContent.css'; + +const MAX_TITLE_LENGTH = 100; + +/** + * This is generally copied from ChatThinkingContentPart. We are still experimenting with both UIs so I'm not + * trying to refactor to share code. Both could probably be simplified when stable. + */ +export class ChatSubagentContentPart extends ChatCollapsibleContentPart implements IChatContentPart { + private wrapper!: HTMLElement; + private isActive: boolean = true; + private hasToolItems: boolean = false; + private readonly isInitiallyComplete: boolean; + private promptContainer: HTMLElement | undefined; + private resultContainer: HTMLElement | undefined; + private lastItemWrapper: HTMLElement | undefined; + private readonly layoutScheduler: RunOnceScheduler; + private description: string; + private agentName: string | undefined; + private prompt: string | undefined; + + /** + * Extracts subagent info (description, agentName, prompt) from a tool invocation. + */ + private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined } { + const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent...'); + + if (toolInvocation.toolId !== RunSubagentTool.Id) { + return { description: defaultDescription, agentName: undefined, prompt: undefined }; + } + + // Check toolSpecificData first (works for both live and serialized) + if (toolInvocation.toolSpecificData?.kind === 'subagent') { + return { + description: toolInvocation.toolSpecificData.description ?? defaultDescription, + agentName: toolInvocation.toolSpecificData.agentName, + prompt: toolInvocation.toolSpecificData.prompt, + }; + } + + // Fallback to parameters for live invocations + if (toolInvocation.kind === 'toolInvocation') { + const state = toolInvocation.state.get(); + const params = state.type !== IChatToolInvocation.StateKind.Streaming ? + state.parameters as IRunSubagentToolInputParams | undefined + : undefined; + return { + description: params?.description ?? defaultDescription, + agentName: params?.agentName, + prompt: params?.prompt, + }; + } + + return { description: defaultDescription, agentName: undefined, prompt: undefined }; + } + + constructor( + public readonly subAgentInvocationId: string, + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + private readonly context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IHoverService hoverService: IHoverService, + ) { + // Extract description, agentName, and prompt from toolInvocation + const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + + // Build title: "AgentName: description" or "Subagent: description" + const prefix = agentName || localize('chat.subagent.prefix', 'Subagent'); + const initialTitle = `${prefix}: ${description}`; + super(initialTitle, context, undefined, hoverService); + + this.description = description; + this.agentName = agentName; + this.prompt = prompt; + this.isInitiallyComplete = this.element.isComplete; + + const node = this.domNode; + node.classList.add('chat-thinking-box', 'chat-thinking-fixed-mode', 'chat-subagent-part'); + node.tabIndex = 0; + + // Hide initially until there are tool calls + node.style.display = 'none'; + + if (this._collapseButton && !this.element.isComplete) { + this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + } + + this._register(autorun(r => { + this.expanded.read(r); + if (this._collapseButton && this.wrapper) { + if (this.wrapper.classList.contains('chat-thinking-streaming') && !this.element.isComplete && this.isActive) { + this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + } else { + this._collapseButton.icon = Codicon.check; + } + } + })); + + // Start collapsed - fixed scrolling mode shows limited height when collapsed + this.setExpanded(false); + + // Scheduler for coalescing layout operations + this.layoutScheduler = this._register(new RunOnceScheduler(() => this.performLayout(), 0)); + + // Render the prompt section at the start if available (must be after wrapper is initialized) + this.renderPromptSection(); + + // Watch for completion and render result + this.watchToolCompletion(toolInvocation); + } + + protected override initContent(): HTMLElement { + const baseClasses = '.chat-used-context-list.chat-thinking-collapsible'; + const classes = this.isInitiallyComplete + ? baseClasses + : `${baseClasses}.chat-thinking-streaming`; + this.wrapper = $(classes); + return this.wrapper; + } + + /** + * Renders the prompt as a collapsible section at the start of the content. + */ + private renderPromptSection(): void { + if (!this.prompt || this.promptContainer) { + return; + } + + // Split into first line and rest + const lines = this.prompt.split('\n'); + const rawFirstLine = lines[0] || localize('chat.subagent.prompt', 'Prompt'); + const restOfLines = lines.slice(1).join('\n').trim(); + + // Limit first line length, moving overflow to content + const titleContent = rcut(rawFirstLine, MAX_TITLE_LENGTH); + const wasTruncated = rawFirstLine.length > MAX_TITLE_LENGTH; + const title = wasTruncated ? titleContent + '…' : titleContent; + const titleRemainder = rawFirstLine.length > titleContent.length ? rawFirstLine.slice(titleContent.length).trim() : ''; + const content = titleRemainder + ? (titleRemainder + (restOfLines ? '\n' + restOfLines : '')) + : (restOfLines || this.prompt); + + // Create collapsible prompt part with comment icon + const collapsiblePart = this._register(this.instantiationService.createInstance( + ChatCollapsibleMarkdownContentPart, + title, + content, + this.context, + this.chatContentMarkdownRenderer + )); + collapsiblePart.icon = Codicon.comment; + this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this.promptContainer = collapsiblePart.domNode; + // Insert at the beginning of the wrapper + if (this.wrapper.firstChild) { + this.wrapper.insertBefore(this.promptContainer, this.wrapper.firstChild); + } else { + dom.append(this.wrapper, this.promptContainer); + } + } + + public getIsActive(): boolean { + return this.isActive; + } + + public markAsInactive(): void { + this.isActive = false; + this.wrapper.classList.remove('chat-thinking-streaming'); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.check; + } + this.finalizeTitle(); + // Collapse when done + this.setExpanded(false); + this._onDidChangeHeight.fire(); + } + + public finalizeTitle(): void { + this.updateTitle(); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.check; + } + } + + private updateTitle(): void { + if (this._collapseButton) { + const prefix = this.agentName || localize('chat.subagent.prefix', 'Subagent'); + const finalLabel = `${prefix}: ${this.description}`; + this._collapseButton.label = finalLabel; + } + } + + /** + * Watches the tool invocation for completion and renders the result. + * Handles both live and serialized invocations. + */ + private watchToolCompletion(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (toolInvocation.toolId !== RunSubagentTool.Id) { + return; + } + + if (toolInvocation.kind === 'toolInvocation') { + // Watch for completion and render the result + let wasStreaming = toolInvocation.state.get().type === IChatToolInvocation.StateKind.Streaming; + this._register(autorun(r => { + const state = toolInvocation.state.read(r); + if (state.type === IChatToolInvocation.StateKind.Completed) { + wasStreaming = false; + // Extract text from result + const textParts = (state.contentForModel || []) + .filter((part): part is { kind: 'text'; value: string } => part.kind === 'text') + .map(part => part.value); + + if (textParts.length > 0) { + this.renderResultText(textParts.join('\n')); + } + + // Mark as inactive when the tool completes + this.markAsInactive(); + } else if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { + wasStreaming = false; + // Update things that change when tool is done streaming + const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + this.description = description; + this.agentName = agentName; + this.prompt = prompt; + this.renderPromptSection(); + this.updateTitle(); + } + })); + } else if (toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.result) { + // Render the persisted result for serialized invocations + this.renderResultText(toolInvocation.toolSpecificData.result); + // Already complete, mark as inactive + this.markAsInactive(); + } + } + + public renderResultText(resultText: string): void { + if (this.resultContainer || !resultText) { + return; // Already rendered or no content + } + + // Split into first line and rest + const lines = resultText.split('\n'); + const rawFirstLine = lines[0] || ''; + const restOfLines = lines.slice(1).join('\n').trim(); + + // Limit first line length, moving overflow to content + const titleContent = rcut(rawFirstLine, MAX_TITLE_LENGTH); + const wasTruncated = rawFirstLine.length > MAX_TITLE_LENGTH; + const title = wasTruncated ? titleContent + '…' : titleContent; + const titleRemainder = rawFirstLine.length > titleContent.length ? rawFirstLine.slice(titleContent.length).trim() : ''; + const content = titleRemainder + ? (titleRemainder + (restOfLines ? '\n' + restOfLines : '')) + : restOfLines; + + // Create collapsible result part + const collapsiblePart = this._register(this.instantiationService.createInstance( + ChatCollapsibleMarkdownContentPart, + title, + content, + this.context, + this.chatContentMarkdownRenderer + )); + this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this.resultContainer = collapsiblePart.domNode; + dom.append(this.wrapper, this.resultContainer); + + // Show the container if it was hidden + if (this.domNode.style.display === 'none') { + this.domNode.style.display = ''; + } + + this._onDidChangeHeight.fire(); + } + + public appendItem(content: HTMLElement, toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (!content.hasChildNodes() || content.textContent?.trim() === '') { + return; + } + + // Show the container when first tool item is added + if (!this.hasToolItems) { + this.hasToolItems = true; + this.domNode.style.display = ''; + } + + // Wrap with icon like thinking parts do, but skip icon for tools needing confirmation + const itemWrapper = $('.chat-thinking-tool-wrapper'); + let needsConfirmation = false; + if (toolInvocation.kind === 'toolInvocation' && toolInvocation.state) { + const state = toolInvocation.state.get(); + needsConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; + } + + if (!needsConfirmation) { + const icon = getToolInvocationIcon(toolInvocation.toolId); + const iconElement = createThinkingIcon(icon); + itemWrapper.appendChild(iconElement); + } + itemWrapper.appendChild(content); + + // Insert before result container if it exists, otherwise append + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); + } else { + this.wrapper.appendChild(itemWrapper); + } + this.lastItemWrapper = itemWrapper; + + // Watch for tool completion to update height when label changes + if (toolInvocation.kind === 'toolInvocation') { + this._register(autorun(r => { + const state = toolInvocation.state.read(r); + if (state.type === IChatToolInvocation.StateKind.Completed) { + this._onDidChangeHeight.fire(); + } + })); + } + + // Schedule layout to measure last item and scroll + this.layoutScheduler.schedule(); + } + + private performLayout(): void { + // Measure last item height once after layout, set CSS variable for collapsed max-height + if (this.lastItemWrapper) { + const itemHeight = this.lastItemWrapper.offsetHeight; + const height = itemHeight + 4; + if (height > 0) { + this.wrapper.style.setProperty('--chat-subagent-last-item-height', `${height}px`); + } + } + + // Auto-scroll to bottom only when actively streaming (not for completed responses) + if (this.isActive && !this.isInitiallyComplete) { + const scrollHeight = this.wrapper.scrollHeight; + this.wrapper.scrollTop = scrollHeight; + } + + this._onDidChangeHeight.fire(); + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped + if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || other.toolId === RunSubagentTool.Id)) { + // For runSubagent tool, use toolCallId as the effective ID + const otherEffectiveId = other.toolId === RunSubagentTool.Id ? other.toolCallId : other.subAgentInvocationId; + // If both have IDs, they must match + if (this.subAgentInvocationId && otherEffectiveId) { + return this.subAgentInvocationId === otherEffectiveId; + } + // Fallback for tools without IDs - group if this part has no ID and tool has no ID + return !this.subAgentInvocationId && !otherEffectiveId; + } + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index d26cbe3869f..14576106a78 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -35,7 +35,7 @@ function extractTextFromPart(content: IChatThinkingPart): string { return raw.trim(); } -function getToolInvocationIcon(toolId: string): ThemeIcon { +export function getToolInvocationIcon(toolId: string): ThemeIcon { const lowerToolId = toolId.toLowerCase(); if ( @@ -69,7 +69,7 @@ function getToolInvocationIcon(toolId: string): ThemeIcon { return Codicon.tools; } -function createThinkingIcon(icon: ThemeIcon): HTMLElement { +export function createThinkingIcon(icon: ThemeIcon): HTMLElement { const iconElement = $('span.chat-thinking-icon'); iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); return iconElement; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index eac8d7063fb..8b38d206e06 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -84,7 +84,6 @@ export interface ICodeBlockData { readonly languageId: string; readonly codemapperUri?: URI; - readonly fromSubagent?: boolean; readonly vulns?: readonly IMarkdownVulnerability[]; readonly range?: Range; 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 new file mode 100644 index 00000000000..417be791784 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Subagent-specific styles */ +.interactive-session .interactive-response .value .chat-thinking-fixed-mode.chat-subagent-part { + /* Collapsed + streaming: show only the last item with max-height */ + &.chat-used-context-collapsed .chat-used-context-list.chat-thinking-collapsible.chat-thinking-streaming { + max-height: var(--chat-subagent-last-item-height, 200px); + overflow: hidden; + display: block; + } + + /* Expanded: show all content, no max-height, no scrolling */ + .chat-used-context-list.chat-thinking-collapsible { + max-height: none; + overflow: visible; + } +} + +/* Subagent result collapsible section */ +.chat-subagent-result { + margin-top: 4px; + padding: 4px 8px; + + .chat-used-context-label { + cursor: pointer; + + .monaco-button { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-s); + } + } + + .chat-subagent-result-content { + padding: 4px 8px 4px 20px; + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + + p { + margin: 0; + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 7947d601b76..c541a208ec7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -95,11 +95,6 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS ) { super(toolInvocation); - // Tag for sub-agent styling - if (toolInvocation.fromSubAgent) { - context.container.classList.add('from-sub-agent'); - } - const state = toolInvocation.state.get(); if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { throw new Error('Confirmation messages are missing'); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 24f8727d48b..b36a2b71901 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -900,7 +900,7 @@ class ChatTerminalToolOutputSection extends Disposable { private async _createScrollableContainer(): Promise { this._scrollableContainer = this._register(new DomScrollableElement(this._outputBody, { vertical: ScrollbarVisibility.Hidden, - horizontal: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, handleMouseWheel: true })); const scrollableDomNode = this._scrollableContainer.getDomNode(); @@ -908,6 +908,20 @@ class ChatTerminalToolOutputSection extends Disposable { this.domNode.appendChild(scrollableDomNode); this.updateAriaLabel(); + // Show horizontal scrollbar on hover/focus, hide otherwise to prevent flickering during streaming + this._register(dom.addDisposableListener(this.domNode, dom.EventType.MOUSE_ENTER, () => { + this._scrollableContainer?.updateOptions({ horizontal: ScrollbarVisibility.Auto }); + })); + this._register(dom.addDisposableListener(this.domNode, dom.EventType.MOUSE_LEAVE, () => { + this._scrollableContainer?.updateOptions({ horizontal: ScrollbarVisibility.Hidden }); + })); + this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_IN, () => { + this._scrollableContainer?.updateOptions({ horizontal: ScrollbarVisibility.Auto }); + })); + this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_OUT, () => { + this._scrollableContainer?.updateOptions({ horizontal: ScrollbarVisibility.Hidden }); + })); + // Track scroll state to enable scroll lock behavior (only for user scrolls) this._register(this._scrollableContainer.onScroll(() => { if (this._isProgrammaticScroll) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index dffa3138a9b..150d5bd1beb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -78,11 +78,6 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { partType: 'chatToolConfirmation', subtitle: typeof toolInvocation.originMessage === 'string' ? toolInvocation.originMessage : toolInvocation.originMessage?.value, }); - - // Tag for sub-agent styling - if (toolInvocation.fromSubAgent) { - context.container.classList.add('from-sub-agent'); - } } protected override additionalPrimaryActions() { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 553a1532a30..5e079e24834 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -68,9 +68,6 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa super(); this.domNode = dom.$('.chat-tool-invocation-part'); - if (toolInvocation.fromSubAgent) { - this.domNode.classList.add('from-sub-agent'); - } if (toolInvocation.presentation === 'hidden') { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index f16c95fde19..3b9e4582e94 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -41,6 +41,10 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { if (isComplete && this.toolIsConfirmed && this.toolInvocation.pastTenseMessage) { const key = this.getAnnouncementKey('complete'); const completionContent = this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage; + // Don't render anything if there's no meaningful content + if (!this.hasMeaningfulContent(completionContent)) { + return document.createElement('div'); + } const shouldAnnounce = this.toolInvocation.kind === 'toolInvocation' && this.hasMeaningfulContent(completionContent) ? this.computeShouldAnnounce(key) : false; const part = this.renderProgressContent(completionContent, shouldAnnounce); this._register(part); @@ -52,6 +56,11 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { const progress = progressObservable?.read(reader); const key = this.getAnnouncementKey('progress'); const progressContent = progress?.message ?? this.toolInvocation.invocationMessage; + // Don't render anything if there's no meaningful content + if (!this.hasMeaningfulContent(progressContent)) { + dom.clearNode(container); + return; + } const shouldAnnounce = this.toolInvocation.kind === 'toolInvocation' && this.hasMeaningfulContent(progressContent) ? this.computeShouldAnnounce(key) : false; const part = reader.store.add(this.renderProgressContent(progressContent, shouldAnnounce)); dom.reset(container, part.domNode); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts index 11d0a6af793..389a8bacda6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts @@ -62,6 +62,13 @@ export class ChatToolStreamingSubPart extends BaseChatToolInvocationSubPart { const streamingMessage = currentState.streamingMessage.read(reader); const displayMessage = streamingMessage ?? toolInvocation.invocationMessage; + // Don't render anything if there's no meaningful content + const messageText = typeof displayMessage === 'string' ? displayMessage : displayMessage.value; + if (!messageText || messageText.trim().length === 0) { + dom.clearNode(container); + return; + } + const content: IMarkdownString = typeof displayMessage === 'string' ? new MarkdownString().appendText(displayMessage) : displayMessage; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c59b8c329a2..ef2be62a646 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -88,12 +88,14 @@ import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, Coll import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; import { ChatThinkingContentPart } from './chatContentParts/chatThinkingContentPart.js'; +import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js'; import { ChatToolInvocationPart } from './chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; import { ChatMarkdownDecorationsRenderer } from './chatContentParts/chatMarkdownDecorationsRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/codeBlockPart.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; +import { RunSubagentTool } from '../../common/tools/builtinTools/runSubagentTool.js'; const $ = dom.$; @@ -743,6 +745,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, subAgentInvocationId?: string): ChatSubagentContentPart | undefined { + if (!renderedParts || renderedParts.length === 0) { + return undefined; + } + + // Search backwards for the most recent subagent part + for (let i = renderedParts.length - 1; i >= 0; i--) { + const part = renderedParts[i]; + if (part instanceof ChatSubagentContentPart) { + // If looking for a specific ID, return the part with that ID regardless of active state + if (subAgentInvocationId && part.subAgentInvocationId === subAgentInvocationId) { + return part; + } + // If no ID specified, only return active parts + if (!subAgentInvocationId && part.getIsActive()) { + return part; + } + } + } + + return undefined; + } + + private finalizeAllSubagentParts(templateData: IChatListItemTemplate): void { + if (!templateData.renderedParts) { + return; + } + + // Finalize all active subagent parts (there can be multiple parallel subagents) + for (const part of templateData.renderedParts) { + if (part instanceof ChatSubagentContentPart && part.getIsActive()) { + part.markAsInactive(); + } + } + } + + private handleSubagentToolGrouping(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, part: ChatToolInvocationPart, subagentId: string, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): ChatSubagentContentPart { + // Finalize any active thinking part since subagent tools have their own grouping + this.finalizeCurrentThinkingPart(context, templateData); + + const lastSubagent = this.getSubagentPart(templateData.renderedParts, subagentId); + if (lastSubagent) { + // Append to existing subagent part with matching ID + // But skip the runSubagent tool itself - we only want child tools + if (toolInvocation.toolId !== RunSubagentTool.Id) { + lastSubagent.appendItem(part.domNode!, toolInvocation); + } + lastSubagent.addDisposable(part); + return lastSubagent; + } + + // Create a new subagent part - it will extract description/agentName/prompt and watch for completion + const subagentPart = this.instantiationService.createInstance(ChatSubagentContentPart, subagentId, toolInvocation, context, this.chatContentMarkdownRenderer); + // Don't append the runSubagent tool itself - its description is already shown in the title + // Only append child tools (those with subAgentInvocationId) + if (toolInvocation.toolId !== RunSubagentTool.Id) { + subagentPart.appendItem(part.domNode!, toolInvocation); + } + subagentPart.addDisposable(part); + subagentPart.addDisposable(subagentPart.onDidChangeHeight(() => { + this.updateItemHeight(templateData); + })); + return subagentPart; + } + private finalizeCurrentThinkingPart(context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): void { const lastThinking = this.getLastThinkingPart(templateData.renderedParts); if (!lastThinking) { @@ -1361,6 +1434,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); if (isResponseVM(context.element) && collapsedToolsMode !== CollapsedToolsDisplayMode.Off) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 70b964b531b..8f91967ab7f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -563,15 +563,6 @@ } } -.interactive-item-container .value .from-sub-agent { - &.chat-tool-invocation-part, - &.chat-confirmation-widget, - &.chat-terminal-confirmation-widget, - &.chat-codeblock-pill-widget { - margin-left: 18px; - } -} - .interactive-item-container .value > .rendered-markdown li > p { margin: 0; } @@ -1029,8 +1020,8 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { cursor: pointer; - padding: 0 3px; - border-radius: 2px; + padding: 3px; + border-radius: 4px; display: inline-flex; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index e78c2ab4a19..0b75f36b5a4 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -838,6 +838,16 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { newSessionsViewerOrientation = width >= ChatViewPane.SESSIONS_SIDEBAR_VIEW_MIN_WIDTH ? AgentSessionsViewerOrientation.SideBySide : AgentSessionsViewerOrientation.Stacked; } + if ( + newSessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked && + width >= ChatViewPane.SESSIONS_SIDEBAR_VIEW_MIN_WIDTH && + this.getViewPositionAndLocation().location === ViewContainerLocation.AuxiliaryBar && + this.layoutService.isAuxiliaryBarMaximized() + ) { + // Always side-by-side in maximized auxiliary bar if space allows + newSessionsViewerOrientation = AgentSessionsViewerOrientation.SideBySide; + } + this.sessionsViewerOrientation = newSessionsViewerOrientation; if (newSessionsViewerOrientation === AgentSessionsViewerOrientation.SideBySide) { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 99b140e6912..7a79e81edc7 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -151,7 +151,6 @@ export interface IChatMarkdownContent { kind: 'markdownContent'; content: IMarkdownString; inlineReferences?: Record; - fromSubagent?: boolean; } export interface IChatTreeData { @@ -449,14 +448,14 @@ export type ConfirmedReason = export interface IChatToolInvocation { readonly presentation: IPreparedToolInvocation['presentation']; - readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent; + readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; readonly originMessage: string | IMarkdownString | undefined; readonly invocationMessage: string | IMarkdownString; readonly pastTenseMessage: string | IMarkdownString | undefined; readonly source: ToolDataSource; readonly toolId: string; readonly toolCallId: string; - readonly fromSubAgent?: boolean; + readonly subAgentInvocationId?: string; readonly state: IObservable; generatedTitle?: string; @@ -707,7 +706,7 @@ export interface IToolResultOutputDetailsSerialized { */ export interface IChatToolInvocationSerialized { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; invocationMessage: string | IMarkdownString; originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; @@ -718,7 +717,7 @@ export interface IChatToolInvocationSerialized { toolCallId: string; toolId: string; source: ToolDataSource; - readonly fromSubAgent?: boolean; + readonly subAgentInvocationId?: string; generatedTitle?: string; kind: 'toolInvocationSerialized'; } @@ -737,6 +736,14 @@ export interface IChatPullRequestContent { kind: 'pullRequest'; } +export interface IChatSubagentToolInvocationData { + kind: 'subagent'; + description?: string; + agentName?: string; + prompt?: string; + result?: string; +} + export interface IChatTodoListContent { kind: 'todoList'; sessionId: string; @@ -990,11 +997,43 @@ export interface IChatSessionStats { removed: number; } -export interface IChatSessionTiming { +export type IChatSessionTiming = { + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted: number | undefined; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded: number | undefined; +}; + +interface ILegacyChatSessionTiming { startTime: number; endTime?: number; } +export function convertLegacyChatSessionTiming(timing: IChatSessionTiming | ILegacyChatSessionTiming): IChatSessionTiming { + if (hasKey(timing, { created: true })) { + return timing; + } + return { + created: timing.startTime, + lastRequestStarted: timing.startTime, + lastRequestEnded: timing.endTime, + }; +} + export const enum ResponseModelState { Pending, Complete, @@ -1007,7 +1046,8 @@ export interface IChatDetail { sessionResource: URI; title: string; lastMessageDate: number; - timing: IChatSessionTiming; + // Also support old timing format for backwards compatibility with persisted data + timing: IChatSessionTiming | ILegacyChatSessionTiming; isActive: boolean; stats?: IChatSessionStats; lastResponseState: ResponseModelState; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index e515c29b76d..97c2637ef07 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -377,7 +377,11 @@ export class ChatService extends Disposable implements IChatService { ...entry, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: entry.timing ?? { startTime: entry.lastMessageDate }, + timing: entry.timing ?? { + created: entry.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: entry.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, @@ -393,7 +397,11 @@ export class ChatService extends Disposable implements IChatService { ...metadata, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: metadata.timing ?? { startTime: metadata.lastMessageDate }, + timing: metadata.timing ?? { + created: metadata.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: metadata.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 76a9b348698..94126a5ffcf 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService } from './chatService/chatService.js'; +import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -73,6 +73,7 @@ export interface IChatSessionsExtensionPoint { readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; } + export interface IChatSessionItem { resource: URI; label: string; @@ -81,10 +82,7 @@ export interface IChatSessionItem { description?: string | IMarkdownString; status?: ChatSessionStatus; tooltip?: string | IMarkdownString; - timing: { - startTime: number; - endTime?: number; - }; + timing: IChatSessionTiming; changes?: { files: number; insertions: number; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 92567694b67..c3d0baa61ad 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -303,9 +303,8 @@ export interface ILanguageModelsService { /** * Given a selector, returns a list of model identifiers * @param selector The selector to lookup for language models. If the selector is empty, all language models are returned. - * @param allowPromptingUser If true the user may be prompted for things like API keys for us to select the model. */ - selectLanguageModels(selector: ILanguageModelChatSelector, allowPromptingUser?: boolean): Promise; + selectLanguageModels(selector: ILanguageModelChatSelector): Promise; registerLanguageModelProvider(vendor: string, provider: ILanguageModelChatProvider): IDisposable; @@ -319,6 +318,8 @@ export interface ILanguageModelsService { removeLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise; configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise; + + migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise; } const languageModelChatProviderType = { @@ -477,6 +478,10 @@ export class LanguageModelsService implements ILanguageModelsService { }); } + private _saveModelPickerPreferences(): void { + this._storageService.store('chatModelPickerPreferences', this._modelPickerUserPreferences, StorageScope.PROFILE, StorageTarget.USER); + } + updateModelPickerPreference(modelIdentifier: string, showInModelPicker: boolean): void { const model = this._modelCache.get(modelIdentifier); if (!model) { @@ -487,9 +492,9 @@ export class LanguageModelsService implements ILanguageModelsService { this._modelPickerUserPreferences[modelIdentifier] = showInModelPicker; if (showInModelPicker === model.isUserSelectable) { delete this._modelPickerUserPreferences[modelIdentifier]; - this._storageService.store('chatModelPickerPreferences', this._modelPickerUserPreferences, StorageScope.PROFILE, StorageTarget.USER); + this._saveModelPickerPreferences(); } else if (model.isUserSelectable !== showInModelPicker) { - this._storageService.store('chatModelPickerPreferences', this._modelPickerUserPreferences, StorageScope.PROFILE, StorageTarget.USER); + this._saveModelPickerPreferences(); } this._onLanguageModelChange.fire(model.vendor); this._logService.trace(`[LM] Updated model picker preference for ${modelIdentifier} to ${showInModelPicker}`); @@ -543,10 +548,17 @@ export class LanguageModelsService implements ILanguageModelsService { const languageModelsGroups: ILanguageModelsGroup[] = []; try { - const models = await this._resolveLanguageModels(provider, { silent }); + const models = await provider.provideLanguageModelChatInfo({ silent }, CancellationToken.None); if (models.length) { allModels.push(...models); - languageModelsGroups.push({ modelIdentifiers: models.map(m => m.identifier) }); + const modelIdentifiers = []; + for (const m of models) { + // Special case for copilot models - they are all user selectable unless marked otherwise + if (vendorId === 'copilot' && (m.metadata.isUserSelectable || this._modelPickerUserPreferences[m.identifier] === true)) { + modelIdentifiers.push(m.identifier); + } + } + languageModelsGroups.push({ modelIdentifiers }); } } catch (error) { languageModelsGroups.push({ @@ -567,7 +579,7 @@ export class LanguageModelsService implements ILanguageModelsService { const configuration = await this._resolveConfiguration(group, vendor.configuration); try { - const models = await this._resolveLanguageModels(provider, { group: group.name, silent, configuration }); + const models = await provider.provideLanguageModelChatInfo({ group: group.name, silent, configuration }, CancellationToken.None); if (models.length) { allModels.push(...models); languageModelsGroups.push({ group, modelIdentifiers: models.map(m => m.identifier) }); @@ -598,29 +610,18 @@ export class LanguageModelsService implements ILanguageModelsService { }); } - private async _resolveLanguageModels(provider: ILanguageModelChatProvider, options: ILanguageModelChatInfoOptions): Promise { - let models = await provider.provideLanguageModelChatInfo(options, CancellationToken.None); - if (models.length) { - // This is a bit of a hack, when prompting user if the provider returns any models that are user selectable then we only want to show those and not the entire model list - if (!options.silent && models.some(m => m.metadata.isUserSelectable)) { - models = models.filter(m => m.metadata.isUserSelectable || this._modelPickerUserPreferences[m.identifier] === true); - } - } - return models; - } - async fetchLanguageModelGroups(vendor: string): Promise { await this._resolveAllLanguageModels(vendor, true); return this._modelsGroups.get(vendor) ?? []; } - async selectLanguageModels(selector: ILanguageModelChatSelector, allowPromptingUser?: boolean): Promise { + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { if (selector.vendor) { - await this._resolveAllLanguageModels(selector.vendor, !allowPromptingUser); + await this._resolveAllLanguageModels(selector.vendor, true); } else { const allVendors = Array.from(this._vendors.keys()); - await Promise.all(allVendors.map(vendor => this._resolveAllLanguageModels(vendor, !allowPromptingUser))); + await Promise.all(allVendors.map(vendor => this._resolveAllLanguageModels(vendor, true))); } const result: string[] = []; @@ -696,7 +697,7 @@ export class LanguageModelsService implements ILanguageModelsService { } if (vendor.managementCommand) { - await this.selectLanguageModels({ vendor: vendor.vendor }, true); + await this._resolveAllLanguageModels(vendor.vendor, false); return; } @@ -1050,6 +1051,31 @@ export class LanguageModelsService implements ILanguageModelsService { } } + async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { + const { vendor, name, ...configuration } = languageModelsProviderGroup; + if (!this._vendors.get(vendor)) { + throw new Error(`Vendor ${vendor} not found.`); + } + + await this._extensionService.activateByEvent(`onLanguageModelChatProvider:${vendor}`); + const provider = this._providers.get(vendor); + if (!provider) { + throw new Error(`Chat model provider for vendor ${vendor} is not registered.`); + } + + const models = await provider.provideLanguageModelChatInfo({ group: name, silent: false, configuration }, CancellationToken.None); + for (const model of models) { + const oldIdentifier = `${vendor}/${model.metadata.id}`; + if (this._modelPickerUserPreferences[oldIdentifier] === true) { + this._modelPickerUserPreferences[model.identifier] = true; + } + delete this._modelPickerUserPreferences[oldIdentifier]; + } + this._saveModelPickerPreferences(); + + await this.addLanguageModelsProviderGroup(name, vendor, configuration); + } + dispose() { this._store.dispose(); this._providers.clear(); diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 8fecdb4ebf8..c32f835af68 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1802,10 +1802,14 @@ export class ChatModel extends Disposable implements IChatModel { } get timing(): IChatSessionTiming { - const lastResponse = this._requests.at(-1)?.response; + const lastRequest = this._requests.at(-1); + const lastResponse = lastRequest?.response; + const lastRequestStarted = lastRequest?.timestamp; + const lastRequestEnded = lastResponse?.completedAt ?? lastResponse?.timestamp; return { - startTime: this._timestamp, - endTime: lastResponse?.completedAt ?? lastResponse?.timestamp + created: this._timestamp, + lastRequestStarted, + lastRequestEnded, }; } @@ -2051,7 +2055,7 @@ export class ChatModel extends Disposable implements IChatModel { agent, slashCommand: raw.slashCommand, requestId: request.id, - modelState: raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() }, + modelState, vote: raw.vote, timestamp: raw.timestamp, voteDownReason: raw.voteDownReason, diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index b5515039ffe..6a24a622c8c 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -6,15 +6,14 @@ import { encodeBase64 } from '../../../../../../base/common/buffer.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; -import { localize } from '../../../../../../nls.js'; -import { ConfirmedReason, IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; +import { ConfirmedReason, IChatExtensionsContent, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; import { IPreparedToolInvocation, isToolResultOutputDetails, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult, ToolDataSource } from '../../tools/languageModelToolsService.js'; export interface IStreamingToolCallOptions { toolCallId: string; toolId: string; toolData: IToolData; - fromSubAgent?: boolean; + subagentInvocationId?: string; chatRequestId?: string; } @@ -28,12 +27,12 @@ export class ChatToolInvocation implements IChatToolInvocation { public presentation: IPreparedToolInvocation['presentation']; public readonly toolId: string; public source: ToolDataSource; - public readonly fromSubAgent: boolean | undefined; + public readonly subAgentInvocationId: string | undefined; public parameters: unknown; public generatedTitle?: string; public readonly chatRequestId?: string; - public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; private readonly _progress = observableValue<{ message?: string | IMarkdownString; progress: number | undefined }>(this, { progress: 0 }); private readonly _state: ISettableObservable; @@ -51,21 +50,19 @@ export class ChatToolInvocation implements IChatToolInvocation { * Use this when the tool call is beginning to stream partial input from the LM. */ public static createStreaming(options: IStreamingToolCallOptions): ChatToolInvocation { - return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.fromSubAgent, undefined, true, options.chatRequestId); + return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.subagentInvocationId, undefined, true, options.chatRequestId); } constructor( preparedInvocation: IPreparedToolInvocation | undefined, toolData: IToolData, public readonly toolCallId: string, - fromSubAgent: boolean | undefined, + subAgentInvocationId: string | undefined, parameters: unknown, isStreaming: boolean = false, chatRequestId?: string ) { - const defaultMessage = localize('toolInvocationMessage', "Using {0}", `"${toolData.displayName}"`); - const invocationMessage = preparedInvocation?.invocationMessage ?? defaultMessage; - this.invocationMessage = invocationMessage; + this.invocationMessage = preparedInvocation?.invocationMessage ?? ''; this.pastTenseMessage = preparedInvocation?.pastTenseMessage; this.originMessage = preparedInvocation?.originMessage; this.confirmationMessages = preparedInvocation?.confirmationMessages; @@ -73,7 +70,7 @@ export class ChatToolInvocation implements IChatToolInvocation { this.toolSpecificData = preparedInvocation?.toolSpecificData; this.toolId = toolData.id; this.source = toolData.source; - this.fromSubAgent = fromSubAgent; + this.subAgentInvocationId = subAgentInvocationId; this.parameters = parameters; this.chatRequestId = chatRequestId; @@ -278,7 +275,7 @@ export class ChatToolInvocation implements IChatToolInvocation { toolSpecificData: this.toolSpecificData, toolCallId: this.toolCallId, toolId: this.toolId, - fromSubAgent: this.fromSubAgent, + subAgentInvocationId: this.subAgentInvocationId, generatedTitle: this.generatedTitle, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 63ac4c99c21..1465a8d5c54 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -665,12 +665,13 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P session.lastMessageDate : session.requests.at(-1)?.timestamp ?? session.creationDate; - const timing = session instanceof ChatModel ? + const timing: IChatSessionTiming = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { - startTime: session.creationDate, - endTime: lastMessageDate + created: session.creationDate, + lastRequestStarted: session.requests.at(-1)?.timestamp, + lastRequestEnded: lastMessageDate, }; return { diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 16d66aa1982..36f2fb547bc 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -149,7 +149,10 @@ export interface IChatAgentRequest { userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; - isSubagent?: boolean; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + */ + subAgentInvocationId?: string; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 1ce37155cb4..00942076e33 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -6,7 +6,8 @@ import type { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { URI } from '../../../../../../base/common/uri.js'; import { PromptsType } from '../promptTypes.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, getPromptFileDefaultLocation } from './promptFileLocations.js'; +import { getPromptFileDefaultLocations, IPromptSourceFolder, PromptFileSource } from './promptFileLocations.js'; +import { PromptsStorage } from '../service/promptsService.js'; /** * Configuration helper for the `reusable prompts` feature. @@ -58,6 +59,11 @@ export namespace PromptsConfig { */ export const MODE_LOCATION_KEY = 'chat.modeFilesLocations'; + /** + * Configuration key for the locations of skill folders. + */ + export const SKILLS_LOCATION_KEY = 'chat.agentSkillsLocations'; + /** * Configuration key for prompt file suggestions. */ @@ -85,7 +91,7 @@ export namespace PromptsConfig { /** * Get value of the `reusable prompt locations` configuration setting. - * @see {@link PROMPT_LOCATIONS_CONFIG_KEY}, {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}, {@link MODE_LOCATIONS_CONFIG_KEY}. + * @see {@link PROMPT_LOCATIONS_CONFIG_KEY}, {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}, {@link MODE_LOCATIONS_CONFIG_KEY}, {@link SKILLS_LOCATION_KEY}. */ export function getLocationsValue(configService: IConfigurationService, type: PromptsType): Record | undefined { const key = getPromptFileLocationsConfigKey(type); @@ -119,29 +125,34 @@ export namespace PromptsConfig { /** * Gets list of source folders for prompt files. - * Defaults to {@link PROMPT_DEFAULT_SOURCE_FOLDER}, {@link INSTRUCTIONS_DEFAULT_SOURCE_FOLDER} or {@link MODE_DEFAULT_SOURCE_FOLDER}. + * Defaults to {@link PROMPT_DEFAULT_SOURCE_FOLDER}, {@link INSTRUCTIONS_DEFAULT_SOURCE_FOLDER}, {@link MODE_DEFAULT_SOURCE_FOLDER} or {@link SKILLS_LOCATION_KEY}. */ - export function promptSourceFolders(configService: IConfigurationService, type: PromptsType): string[] { + export function promptSourceFolders(configService: IConfigurationService, type: PromptsType): IPromptSourceFolder[] { const value = getLocationsValue(configService, type); - const defaultSourceFolder = getPromptFileDefaultLocation(type); + const defaultSourceFolders = getPromptFileDefaultLocations(type); // note! the `value &&` part handles the `undefined`, `null`, and `false` cases if (value && (typeof value === 'object')) { - const paths: string[] = []; + const paths: IPromptSourceFolder[] = []; + const defaultFolderPathsSet = new Set(defaultSourceFolders.map(f => f.path)); - // if the default source folder is not explicitly disabled, add it - if (value[defaultSourceFolder] !== false) { - paths.push(defaultSourceFolder); + // add default source folders that are not explicitly disabled + for (const defaultFolder of defaultSourceFolders) { + if (value[defaultFolder.path] !== false) { + paths.push(defaultFolder); + } } // copy all the enabled paths to the result list for (const [path, enabledValue] of Object.entries(value)) { - // we already added the default source folder, so skip it - if ((enabledValue === false) || (path === defaultSourceFolder)) { + // we already added the default source folders, so skip them + if ((enabledValue === false) || defaultFolderPathsSet.has(path)) { continue; } - paths.push(path); + // determine location type in the general case + const storage = isTildePath(path) ? PromptsStorage.user : PromptsStorage.local; + paths.push({ path, source: storage === PromptsStorage.local ? PromptFileSource.ConfigPersonal : PromptFileSource.ConfigWorkspace, storage }); } return paths; @@ -211,6 +222,8 @@ export function getPromptFileLocationsConfigKey(type: PromptsType): string { return PromptsConfig.PROMPT_LOCATIONS_KEY; case PromptsType.agent: return PromptsConfig.MODE_LOCATION_KEY; + case PromptsType.skill: + return PromptsConfig.SKILLS_LOCATION_KEY; default: throw new Error('Unknown prompt type'); } @@ -244,3 +257,14 @@ export function asBoolean(value: unknown): boolean | undefined { return undefined; } + +/** + * Helper to check if a path starts with tilde (user home). + * Supports both Unix-style (`~/`) and Windows-style (`~\`) paths. + * + * @param path - path to check + * @returns `true` if the path starts with `~/` or `~\` + */ +export function isTildePath(path: string): boolean { + return path.startsWith('~/') || path.startsWith('~\\'); +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 5240b09f7c9..fb143dcf2be 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -6,6 +6,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../../base/common/path.js'; import { PromptsType } from '../promptTypes.js'; +import { PromptsStorage } from '../service/promptsService.js'; /** * File extension for the reusable prompt files. @@ -27,6 +28,11 @@ export const LEGACY_MODE_FILE_EXTENSION = '.chatmode.md'; */ export const AGENT_FILE_EXTENSION = '.agent.md'; +/** + * Skill file name (case insensitive). + */ +export const SKILL_FILENAME = 'SKILL.md'; + /** * Copilot custom instructions file name. */ @@ -54,20 +60,76 @@ export const LEGACY_MODE_DEFAULT_SOURCE_FOLDER = '.github/chatmodes'; export const AGENTS_SOURCE_FOLDER = '.github/agents'; /** - * Default agent skills workspace source folders. + * Tracks where prompt files originate from. */ -export const DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS = [ - { path: '.github/skills', type: 'github-workspace' }, - { path: '.claude/skills', type: 'claude-workspace' } -] as const; +export enum PromptFileSource { + GitHubWorkspace = 'github-workspace', + CopilotPersonal = 'copilot-personal', + ClaudePersonal = 'claude-personal', + ClaudeWorkspace = 'claude-workspace', + ConfigWorkspace = 'config-workspace', + ConfigPersonal = 'config-personal', + ExtensionContribution = 'extension-contribution', + ExtensionAPI = 'extension-api', +} /** - * Default agent skills user home source folders. + * Prompt source folder path with source and storage type. */ -export const DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS = [ - { path: '.copilot/skills', type: 'copilot-personal' }, - { path: '.claude/skills', type: 'claude-personal' } -] as const; +export interface IPromptSourceFolder { + readonly path: string; + readonly source: PromptFileSource; + readonly storage: PromptsStorage; +} + +/** + * Resolved prompt folder with source and storage type. + */ +export interface IResolvedPromptSourceFolder { + readonly uri: URI; + readonly source: PromptFileSource; + readonly storage: PromptsStorage; +} + +/** + * Resolved prompt markdown file with source and storage type. + */ +export interface IResolvedPromptFile { + readonly fileUri: URI; + readonly source: PromptFileSource; + readonly storage: PromptsStorage; +} + +/** + * All default skill source folders (both workspace and user home). + */ +export const DEFAULT_SKILL_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: '.github/skills', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: '.claude/skills', source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, + { path: '~/.copilot/skills', source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, + { path: '~/.claude/skills', source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, +]; + +/** + * Default instructions source folders. + */ +export const DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, +]; + +/** + * Default prompt source folders. + */ +export const DEFAULT_PROMPT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: PROMPT_DEFAULT_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, +]; + +/** + * Default agent source folders. + */ +export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: AGENTS_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, +]; /** * Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders). @@ -95,6 +157,10 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { return PromptsType.agent; } + if (filename.toLowerCase() === SKILL_FILENAME.toLowerCase()) { + return PromptsType.skill; + } + // Check if it's a .md file in the .github/agents/ folder if (filename.endsWith('.md') && isInAgentsFolder(fileUri)) { return PromptsType.agent; @@ -118,19 +184,23 @@ export function getPromptFileExtension(type: PromptsType): string { return PROMPT_FILE_EXTENSION; case PromptsType.agent: return AGENT_FILE_EXTENSION; + case PromptsType.skill: + return SKILL_FILENAME; default: throw new Error('Unknown prompt type'); } } -export function getPromptFileDefaultLocation(type: PromptsType): string { +export function getPromptFileDefaultLocations(type: PromptsType): readonly IPromptSourceFolder[] { switch (type) { case PromptsType.instructions: - return INSTRUCTIONS_DEFAULT_SOURCE_FOLDER; + return DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS; case PromptsType.prompt: - return PROMPT_DEFAULT_SOURCE_FOLDER; + return DEFAULT_PROMPT_SOURCE_FOLDERS; case PromptsType.agent: - return AGENTS_SOURCE_FOLDER; + return DEFAULT_AGENT_SOURCE_FOLDERS; + case PromptsType.skill: + return DEFAULT_SKILL_SOURCE_FOLDERS; default: throw new Error('Unknown prompt type'); } @@ -160,6 +230,11 @@ export function getCleanPromptName(fileUri: URI): string { return basename(fileUri.path, '.md'); } + // For SKILL.md files (case insensitive), return 'SKILL' + if (fileName.toLowerCase() === SKILL_FILENAME.toLowerCase()) { + return basename(fileUri.path, '.md'); + } + // For .md files in .github/agents/ folder, treat them as agent files if (fileName.endsWith('.md') && isInAgentsFolder(fileUri)) { return basename(fileUri.path, '.md'); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 3e31dee613b..a6c552c3b90 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -499,13 +499,15 @@ export class PromptValidator { const allAttributeNames = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], - [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer] + [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer], + [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description], }; const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, PromptHeaderAttributes.infer]; const recommendedAttributeNames = { [PromptsType.prompt]: allAttributeNames[PromptsType.prompt].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.instructions]: allAttributeNames[PromptsType.instructions].filter(name => !isNonRecommendedAttribute(name)), - [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)) + [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.skill]: allAttributeNames[PromptsType.skill].filter(name => !isNonRecommendedAttribute(name)), }; export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, isGitHubTarget: boolean): string[] { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 9ae26e570af..7da38b26d22 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -11,6 +11,7 @@ import { LanguageSelector } from '../../../../../editor/common/languageSelector. export const PROMPT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-prompt-snippets'; export const INSTRUCTIONS_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-instructions'; export const AGENT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-chat-modes'; // todo +export const SKILL_DOCUMENTATION_URL = 'https://aka.ms/vscode-agent-skills'; /** * Language ID for the reusable prompt syntax. @@ -28,12 +29,17 @@ export const INSTRUCTIONS_LANGUAGE_ID = 'instructions'; export const AGENT_LANGUAGE_ID = 'chatagent'; /** - * Prompt and instructions files language selector. + * Language ID for skill syntax. */ -export const ALL_PROMPTS_LANGUAGE_SELECTOR: LanguageSelector = [PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID]; +export const SKILL_LANGUAGE_ID = 'skill'; /** - * The language id for for a prompts type. + * Prompt and instructions files language selector. + */ +export const ALL_PROMPTS_LANGUAGE_SELECTOR: LanguageSelector = [PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID, SKILL_LANGUAGE_ID]; + +/** + * The language id for a prompts type. */ export function getLanguageIdForPromptsType(type: PromptsType): string { switch (type) { @@ -43,6 +49,8 @@ export function getLanguageIdForPromptsType(type: PromptsType): string { return INSTRUCTIONS_LANGUAGE_ID; case PromptsType.agent: return AGENT_LANGUAGE_ID; + case PromptsType.skill: + return SKILL_LANGUAGE_ID; default: throw new Error(`Unknown prompt type: ${type}`); } @@ -56,6 +64,8 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u return PromptsType.instructions; case AGENT_LANGUAGE_ID: return PromptsType.agent; + case SKILL_LANGUAGE_ID: + return PromptsType.skill; default: return undefined; } @@ -68,7 +78,8 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u export enum PromptsType { instructions = 'instructions', prompt = 'prompt', - agent = 'agent' + agent = 'agent', + skill = 'skill' } export function isValidPromptType(type: string): type is PromptsType { return Object.values(PromptsType).includes(type as PromptsType); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 7d7f2051645..ccf1a4c42f4 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -21,6 +21,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; export const CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT = 'onCustomAgentProvider'; export const INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT = 'onInstructionsProvider'; export const PROMPT_FILE_PROVIDER_ACTIVATION_EVENT = 'onPromptFileProvider'; +export const SKILL_PROVIDER_ACTIVATION_EVENT = 'onSkillProvider'; /** * Context for querying prompt files. @@ -192,7 +193,7 @@ export interface IChatPromptSlashCommand { export interface IAgentSkill { readonly uri: URI; - readonly type: 'personal' | 'project'; + readonly storage: PromptsStorage; readonly name: string; readonly description: string | undefined; } @@ -222,7 +223,7 @@ export interface IPromptsService extends IDisposable { /** * Get a list of prompt source folders based on the provided prompt type. */ - getSourceFolders(type: PromptsType): readonly IPromptPath[]; + getSourceFolders(type: PromptsType): Promise; /** * Validates if the provided command name is a valid prompt slash command. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 63a78454b07..eac1705a2d0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -8,7 +8,7 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; -import { dirname, isEqual } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; import { type ITextModel } from '../../../../../../editor/common/model.js'; @@ -28,11 +28,11 @@ import { IUserDataProfileService } from '../../../../../services/userDataProfile import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { getCleanPromptName } from '../config/promptFileLocations.js'; +import { getCleanPromptName, IResolvedPromptFile, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT } from './promptsService.js'; +import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -83,6 +83,7 @@ export class PromptsService extends Disposable implements IPromptsService { [PromptsType.prompt]: new ResourceMap>(), [PromptsType.instructions]: new ResourceMap>(), [PromptsType.agent]: new ResourceMap>(), + [PromptsType.skill]: new ResourceMap>(), }; constructor( @@ -240,8 +241,8 @@ export class PromptsService extends Disposable implements IPromptsService { /** * Shared helper to list prompt files from registered providers for a given type. */ - private async listFromProviders(type: PromptsType, activationEvent: string, token: CancellationToken): Promise { - const result: IPromptPath[] = []; + private async listFromProviders(type: PromptsType, activationEvent: string, token: CancellationToken): Promise { + const result: IExtensionPromptPath[] = []; // Activate extensions that might provide files for this type await this.extensionService.activateByEvent(activationEvent); @@ -300,7 +301,7 @@ export class PromptsService extends Disposable implements IPromptsService { } } - private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { + private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { await this.extensionService.whenInstalledExtensionsRegistered(); const contributedFiles = await Promise.all(this.contributedFiles[type].values()); @@ -317,19 +318,21 @@ export class PromptsService extends Disposable implements IPromptsService { return INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT; case PromptsType.prompt: return PROMPT_FILE_PROVIDER_ACTIVATION_EVENT; + case PromptsType.skill: + return SKILL_PROVIDER_ACTIVATION_EVENT; } } - public getSourceFolders(type: PromptsType): readonly IPromptPath[] { + public async getSourceFolders(type: PromptsType): Promise { const result: IPromptPath[] = []; if (type === PromptsType.agent) { - const folders = this.fileLocator.getAgentSourceFolder(); + const folders = await this.fileLocator.getAgentSourceFolders(); for (const uri of folders) { result.push({ uri, storage: PromptsStorage.local, type }); } } else { - for (const uri of this.fileLocator.getConfigBasedSourceFolders(type)) { + for (const uri of await this.fileLocator.getConfigBasedSourceFolders(type)) { result.push({ uri, storage: PromptsStorage.local, type }); } } @@ -663,8 +666,9 @@ export class PromptsService extends Disposable implements IPromptsService { let skippedMissingName = 0; let skippedDuplicateName = 0; let skippedParseFailed = 0; + let skippedNameMismatch = 0; - const process = async (uri: URI, skillType: string, scopeType: 'personal' | 'project'): Promise => { + const process = async (uri: URI, source: PromptFileSource, storage: PromptsStorage): Promise => { try { const parsedFile = await this.parseNew(uri, token); const name = parsedFile.header?.name; @@ -674,8 +678,18 @@ export class PromptsService extends Disposable implements IPromptsService { return; } + // Sanitize the name first (remove XML tags and truncate) const sanitizedName = this.truncateAgentSkillName(name, uri); + // Validate that the sanitized name matches the parent folder name (per agentskills.io specification) + const skillFolderUri = dirname(uri); + const folderName = basename(skillFolderUri); + if (sanitizedName !== folderName) { + skippedNameMismatch++; + this.logger.error(`[findAgentSkills] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); + return; + } + // Check for duplicate names if (seenNames.has(sanitizedName)) { skippedDuplicateName++; @@ -685,20 +699,49 @@ export class PromptsService extends Disposable implements IPromptsService { seenNames.add(sanitizedName); const sanitizedDescription = this.truncateAgentSkillDescription(parsedFile.header?.description, uri); - result.push({ uri, type: scopeType, name: sanitizedName, description: sanitizedDescription } satisfies IAgentSkill); + result.push({ uri, storage, name: sanitizedName, description: sanitizedDescription } satisfies IAgentSkill); // Track skill type - skillTypes.set(skillType, (skillTypes.get(skillType) || 0) + 1); + skillTypes.set(source, (skillTypes.get(source) || 0) + 1); } catch (e) { skippedParseFailed++; this.logger.error(`[findAgentSkills] Failed to parse Agent skill file: ${uri}`, e instanceof Error ? e.message : String(e)); } }; - const workspaceSkills = await this.fileLocator.findAgentSkillsInWorkspace(token); - await Promise.all(workspaceSkills.map(({ uri, type }) => process(uri, type, 'project'))); - const userSkills = await this.fileLocator.findAgentSkillsInUserHome(token); - await Promise.all(userSkills.map(({ uri, type }) => process(uri, type, 'personal'))); + // Collect all skills with their metadata for sorting + const allSkills: Array = []; + const discoveredSkills = await this.fileLocator.findAgentSkills(token); + const extensionSkills = await this.getExtensionPromptFiles(PromptsType.skill, token); + allSkills.push(...discoveredSkills, ...extensionSkills.map((extPath) => ( + { + fileUri: extPath.uri, + storage: extPath.storage, + source: extPath.source === ExtensionAgentSourceType.contribution ? PromptFileSource.ExtensionContribution : PromptFileSource.ExtensionAPI + }))); + + const getPriority = (skill: IResolvedPromptFile | IExtensionPromptPath): number => { + if (skill.storage === PromptsStorage.local) { + return 0; // workspace + } + if (skill.storage === PromptsStorage.user) { + return 1; // personal + } + if (skill.source === PromptFileSource.ExtensionAPI) { + return 2; + } + if (skill.source === PromptFileSource.ExtensionContribution) { + return 3; + } + return 4; + }; + // Stable sort; we should keep order consistent to the order in the user's configuration object + allSkills.sort((a, b) => getPriority(a) - getPriority(b)); + + // Process sequentially to maintain order (important for duplicate name resolution) + for (const skill of allSkills) { + await process(skill.fileUri, skill.source, skill.storage); + } // Send telemetry about skill usage type AgentSkillsFoundEvent = { @@ -707,10 +750,13 @@ export class PromptsService extends Disposable implements IPromptsService { claudeWorkspace: number; copilotPersonal: number; githubWorkspace: number; - customPersonal: number; - customWorkspace: number; + configPersonal: number; + configWorkspace: number; + extensionContribution: number; + extensionAPI: number; skippedDuplicateName: number; skippedMissingName: number; + skippedNameMismatch: number; skippedParseFailed: number; }; @@ -720,10 +766,13 @@ export class PromptsService extends Disposable implements IPromptsService { claudeWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Claude workspace skills.' }; copilotPersonal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Copilot personal skills.' }; githubWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of GitHub workspace skills.' }; - customPersonal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom personal skills.' }; - customWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom workspace skills.' }; + configPersonal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom configured personal skills.' }; + configWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom configured workspace skills.' }; + extensionContribution: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension contributed skills.' }; + extensionAPI: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension API provided skills.' }; skippedDuplicateName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to duplicate names.' }; skippedMissingName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing name attribute.' }; + skippedNameMismatch: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to name not matching folder name.' }; skippedParseFailed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to parse failures.' }; owner: 'pwang347'; comment: 'Tracks agent skill usage, discovery, and skipped files.'; @@ -731,14 +780,17 @@ export class PromptsService extends Disposable implements IPromptsService { this.telemetryService.publicLog2('agentSkillsFound', { totalSkillsFound: result.length, - claudePersonal: skillTypes.get('claude-personal') ?? 0, - claudeWorkspace: skillTypes.get('claude-workspace') ?? 0, - copilotPersonal: skillTypes.get('copilot-personal') ?? 0, - githubWorkspace: skillTypes.get('github-workspace') ?? 0, - customPersonal: skillTypes.get('custom-personal') ?? 0, - customWorkspace: skillTypes.get('custom-workspace') ?? 0, + claudePersonal: skillTypes.get(PromptFileSource.ClaudePersonal) ?? 0, + claudeWorkspace: skillTypes.get(PromptFileSource.ClaudeWorkspace) ?? 0, + copilotPersonal: skillTypes.get(PromptFileSource.CopilotPersonal) ?? 0, + githubWorkspace: skillTypes.get(PromptFileSource.GitHubWorkspace) ?? 0, + configWorkspace: skillTypes.get(PromptFileSource.ConfigWorkspace) ?? 0, + configPersonal: skillTypes.get(PromptFileSource.ConfigPersonal) ?? 0, + extensionContribution: skillTypes.get(PromptFileSource.ExtensionContribution) ?? 0, + extensionAPI: skillTypes.get(PromptFileSource.ExtensionAPI) ?? 0, skippedDuplicateName, skippedMissingName, + skippedNameMismatch, skippedParseFailed }); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 0fb275283c0..015c6c54040 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -7,11 +7,11 @@ import { URI } from '../../../../../../base/common/uri.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { getPromptFileLocationsConfigKey, PromptsConfig } from '../config/config.js'; +import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS, DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, IResolvedPromptFile, IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -57,8 +57,51 @@ export class PromptFilesLocator { } private async listFilesInUserData(type: PromptsType, token: CancellationToken): Promise { - const files = await this.resolveFilesAtLocation(this.userDataService.currentProfile.promptsHome, token); - return files.filter(file => getPromptFileType(file) === type); + const userHome = await this.pathService.userHome(); + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); + const absoluteLocations = type === PromptsType.skill + ? this.toAbsoluteLocationsForSkills(configuredLocations, userHome) + : this.toAbsoluteLocations(configuredLocations, userHome); + + const paths = new ResourceSet(); + for (const { uri, storage } of absoluteLocations) { + if (storage !== PromptsStorage.user) { + continue; + } + const files = await this.resolveFilesAtLocation(uri, type, token); + for (const file of files) { + if (getPromptFileType(file) === type) { + paths.add(file); + } + } + if (token.isCancellationRequested) { + return []; + } + } + + return [...paths]; + } + + /** + * Gets all source folder URIs for a prompt type (both workspace and user home). + * This is used for file watching to detect changes in all relevant locations. + */ + private getSourceFoldersSync(type: PromptsType, userHome: URI): readonly URI[] { + const result: URI[] = []; + const { folders } = this.workspaceService.getWorkspace(); + const defaultFolders = getPromptFileDefaultLocations(type); + + for (const sourceFolder of defaultFolders) { + if (sourceFolder.storage === PromptsStorage.local) { + for (const workspaceFolder of folders) { + result.push(joinPath(workspaceFolder.uri, sourceFolder.path)); + } + } else if (sourceFolder.storage === PromptsStorage.user) { + result.push(joinPath(userHome, sourceFolder.path)); + } + } + + return result; } public createFilesUpdatedEvent(type: PromptsType): { readonly event: Event; dispose: () => void } { @@ -69,6 +112,7 @@ export class PromptFilesLocator { const key = getPromptFileLocationsConfigKey(type); let parentFolders = this.getLocalParentFolders(type); + let allSourceFolders: URI[] = []; const externalFolderWatchers = disposables.add(new DisposableStore()); const updateExternalFolderWatchers = () => { @@ -80,8 +124,20 @@ export class PromptFilesLocator { externalFolderWatchers.add(this.fileService.watch(folder.parent, { recursive, excludes: [] })); } } + // Watch all source folders (including user home if applicable) + for (const folder of allSourceFolders) { + if (!this.workspaceService.getWorkspaceFolder(folder)) { + externalFolderWatchers.add(this.fileService.watch(folder, { recursive: true, excludes: [] })); + } + } }; - updateExternalFolderWatchers(); + + // Initialize source folders (async if type has userHome locations) + this.pathService.userHome().then(userHome => { + allSourceFolders = [...this.getSourceFoldersSync(type, userHome)]; + updateExternalFolderWatchers(); + }); + disposables.add(this.configService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(key)) { parentFolders = this.getLocalParentFolders(type); @@ -98,14 +154,19 @@ export class PromptFilesLocator { eventEmitter.fire(); return; } + if (allSourceFolders.some(folder => e.affects(folder))) { + eventEmitter.fire(); + return; + } })); disposables.add(this.fileService.watch(userDataFolder)); return { event: eventEmitter.event, dispose: () => disposables.dispose() }; } - public getAgentSourceFolder(): readonly URI[] { - return this.toAbsoluteLocations([AGENTS_SOURCE_FOLDER]); + public async getAgentSourceFolders(): Promise { + const userHome = await this.pathService.userHome(); + return this.toAbsoluteLocations(DEFAULT_AGENT_SOURCE_FOLDERS, userHome).map(l => l.uri); } /** @@ -120,9 +181,17 @@ export class PromptFilesLocator { * * @returns List of possible unambiguous prompt file folders. */ - public getConfigBasedSourceFolders(type: PromptsType): readonly URI[] { + public async getConfigBasedSourceFolders(type: PromptsType): Promise { + const userHome = await this.pathService.userHome(); const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); - const absoluteLocations = this.toAbsoluteLocations(configuredLocations); + + // No extra processing needed for skills, since we do not support glob patterns + if (type === PromptsType.skill) { + return this.toAbsoluteLocationsForSkills(configuredLocations, userHome).map(l => l.uri); + } + + // For other types, use the existing logic with glob pattern filtering + const absoluteLocations = this.toAbsoluteLocations(configuredLocations, userHome).map(l => l.uri); // locations in the settings can contain glob patterns so we need // to process them to get "clean" paths; the goal here is to have @@ -171,7 +240,7 @@ export class PromptFilesLocator { for (const { parent, filePattern } of this.getLocalParentFolders(type)) { const files = (filePattern === undefined) - ? await this.resolveFilesAtLocation(parent, token) // if the location does not contain a glob pattern, resolve the location directly + ? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly : await this.searchFilesInLocation(parent, filePattern, token); for (const file of files) { if (getPromptFileType(file) === type) { @@ -189,23 +258,41 @@ export class PromptFilesLocator { private getLocalParentFolders(type: PromptsType): readonly { parent: URI; filePattern?: string }[] { const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); if (type === PromptsType.agent) { - configuredLocations.push(AGENTS_SOURCE_FOLDER); + configuredLocations.push(...DEFAULT_AGENT_SOURCE_FOLDERS); } - const absoluteLocations = this.toAbsoluteLocations(configuredLocations); - return absoluteLocations.map(firstNonGlobParentAndPattern); + const absoluteLocations = type === PromptsType.skill ? + this.toAbsoluteLocationsForSkills(configuredLocations, undefined) : this.toAbsoluteLocations(configuredLocations, undefined); + return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri)); } /** - * Converts locations defined in `settings` to absolute filesystem path URIs. + * Converts locations defined in `settings` to absolute filesystem path URIs with metadata. * This conversion is needed because locations in settings can be relative, * hence we need to resolve them based on the current workspace folders. + * If userHome is provided, paths starting with `~` will be expanded. Otherwise these paths are ignored. + * Preserves the type and location properties from the source folder definitions. */ - private toAbsoluteLocations(configuredLocations: readonly string[]): readonly URI[] { - const result = new ResourceSet(); + private toAbsoluteLocations(configuredLocations: readonly IPromptSourceFolder[], userHome: URI | undefined): readonly IResolvedPromptSourceFolder[] { + const result: IResolvedPromptSourceFolder[] = []; + const seen = new ResourceSet(); const { folders } = this.workspaceService.getWorkspace(); - for (const configuredLocation of configuredLocations) { + for (const sourceFolder of configuredLocations) { + const configuredLocation = sourceFolder.path; try { + // Handle tilde paths when userHome is provided + if (isTildePath(configuredLocation)) { + // If userHome is not provided, we cannot resolve tilde paths so we skip this entry + if (userHome) { + const uri = joinPath(userHome, configuredLocation.substring(2)); + if (!seen.has(uri)) { + seen.add(uri); + result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage }); + } + } + continue; + } + if (isAbsolute(configuredLocation)) { let uri = URI.file(configuredLocation); const remoteAuthority = this.environmentService.remoteAuthority; @@ -214,11 +301,17 @@ export class PromptFilesLocator { // we need to convert it to a file URI with the remote authority uri = uri.with({ scheme: Schemas.vscodeRemote, authority: remoteAuthority }); } - result.add(uri); + if (!seen.has(uri)) { + seen.add(uri); + result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage }); + } } else { for (const workspaceFolder of folders) { const absolutePath = joinPath(workspaceFolder.uri, configuredLocation); - result.add(absolutePath); + if (!seen.has(absolutePath)) { + seen.add(absolutePath); + result.push({ uri: absolutePath, source: sourceFolder.source, storage: sourceFolder.storage }); + } } } } catch (error) { @@ -226,13 +319,42 @@ export class PromptFilesLocator { } } - return [...result]; + return result; + } + + /** + * Converts skill locations to absolute filesystem path URIs with restricted validation. + * Unlike toAbsoluteLocations(), this method enforces stricter rules for skills: + * - No glob patterns (performance concerns) + * - No absolute paths (portability concerns) + * - Only relative paths, tilde paths, and parent relative paths + * + * @param configuredLocations - Source folder definitions from configuration + * @param userHome - User home URI for tilde expansion (optional for workspace-only resolution) + * @returns List of resolved absolute URIs with metadata + */ + private toAbsoluteLocationsForSkills(configuredLocations: readonly IPromptSourceFolder[], userHome: URI | undefined): readonly IResolvedPromptSourceFolder[] { + // Filter and validate skill paths before resolving + const validLocations = configuredLocations.filter(sourceFolder => { + const configuredLocation = sourceFolder.path; + if (!isValidSkillPath(configuredLocation)) { + this.logService.warn(`Skipping invalid skill path (glob patterns and absolute paths not supported): ${configuredLocation}`); + return false; + } + return true; + }); + + // Use the standard resolution logic for valid paths + return this.toAbsoluteLocations(validLocations, userHome); } /** * Uses the file service to resolve the provided location and return either the file at the location of files in the directory. */ - private async resolveFilesAtLocation(location: URI, token: CancellationToken): Promise { + private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken): Promise { + if (type === PromptsType.skill) { + return this.findAgentSkillsInFolder(location, token); + } try { const info = await this.fileService.resolve(location); if (info.isFile) { @@ -367,58 +489,34 @@ export class PromptFilesLocator { return undefined; } - private async findAgentSkillsInFolder(uri: URI, relativePath: string, token: CancellationToken): Promise { - const result = []; + private async findAgentSkillsInFolder(uri: URI, token: CancellationToken): Promise { try { - const stat = await this.fileService.resolve(joinPath(uri, relativePath)); + return await this.searchFilesInLocation(uri, `*/${SKILL_FILENAME}`, token); + } catch (e) { + if (!isCancellationError(e)) { + this.logService.trace(`[PromptFilesLocator] Error searching for skills in ${uri.toString()}: ${e}`); + } + return []; + } + } + + /** + * Searches for skills in all configured locations. + */ + public async findAgentSkills(token: CancellationToken): Promise { + const userHome = await this.pathService.userHome(); + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.skill); + const absoluteLocations = this.toAbsoluteLocationsForSkills(configuredLocations, userHome); + const allResults: IResolvedPromptFile[] = []; + + for (const { uri, source, storage } of absoluteLocations) { if (token.isCancellationRequested) { return []; } - if (stat.isDirectory && stat.children) { - for (const skillDir of stat.children) { - if (skillDir.isDirectory) { - const skillFile = joinPath(skillDir.resource, 'SKILL.md'); - if (await this.fileService.exists(skillFile)) { - result.push(skillFile); - } - } - } - } - } catch (error) { - // no such folder, return empty list - return []; + const results = await this.findAgentSkillsInFolder(uri, token); + allResults.push(...results.map(uri => ({ fileUri: uri, source, storage }))); } - return result; - } - - /** - * Searches for skills in all default directories in the workspace. - * Each skill is stored in its own subdirectory with a SKILL.md file. - */ - public async findAgentSkillsInWorkspace(token: CancellationToken): Promise> { - const workspace = this.workspaceService.getWorkspace(); - const allResults: Array<{ uri: URI; type: string }> = []; - for (const folder of workspace.folders) { - for (const { path, type } of DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS) { - const results = await this.findAgentSkillsInFolder(folder.uri, path, token); - allResults.push(...results.map(uri => ({ uri, type }))); - } - } - return allResults; - } - - /** - * Searches for skills in all default directories in the home folder. - * Each skill is stored in its own subdirectory with a SKILL.md file. - */ - public async findAgentSkillsInUserHome(token: CancellationToken): Promise> { - const userHome = await this.pathService.userHome(); - const allResults: Array<{ uri: URI; type: string }> = []; - for (const { path, type } of DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS) { - const results = await this.findAgentSkillsInFolder(userHome, path, token); - allResults.push(...results.map(uri => ({ uri, type }))); - } return allResults; } } @@ -531,3 +629,33 @@ function firstNonGlobParentAndPattern(location: URI): { parent: URI; filePattern filePattern: segments.slice(i).join('/') }; } + + +/** + * Regex pattern string for validating skill paths. + * Skills only support: + * - Relative paths: someFolder, ./someFolder + * - User home paths: ~/folder or ~\folder + * - Parent relative paths for monorepos: ../folder + * + * NOT supported: + * - Absolute paths (portability issue) + * - Glob patterns with * or ** (performance issue) + * - Tilde without path separator (e.g., ~abc) + * - Empty or whitespace-only paths + * + * The regex validates: + * - Not a Windows absolute path (e.g., C:\) + * - Not starting with / (Unix absolute path) + * - If starts with ~, must be followed by / or \ + * - No glob pattern characters: * ? [ ] { } + * - At least one non-whitespace character + */ +export const VALID_SKILL_PATH_PATTERN = '^(?![A-Za-z]:[\\\\/])(?![\\\\/])(?!~(?![\\\\/]))(?!.*[*?\\[\\]{}]).*\\S.*$'; + +/** + * Validates if a path is allowed for skills configuration. + */ +export function isValidSkillPath(path: string): boolean { + return new RegExp(VALID_SKILL_PATH_PATTERN).test(path); +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 98bccdd257a..cd0c110ca67 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -40,8 +40,6 @@ import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomati import { ManageTodoListToolToolId } from './manageTodoListTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; -export const RunSubagentToolId = 'runSubagent'; - const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. - Agents do not run async or in the background, you will wait for the agent\'s result. @@ -50,7 +48,7 @@ const BaseModelDescription = `Launch a new agent to handle complex, multi-step t - The agent's outputs should generally be trusted - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user\'s intent`; -interface IRunSubagentToolInputParams { +export interface IRunSubagentToolInputParams { prompt: string; description: string; agentName?: string; @@ -58,6 +56,8 @@ interface IRunSubagentToolInputParams { export class RunSubagentTool extends Disposable implements IToolImpl { + static readonly Id = 'runSubagent'; + readonly onDidUpdateToolData: Event; constructor( @@ -100,7 +100,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { modelDescription += `\n- If the user asks for a certain agent, you MUST provide that EXACT agent name (case-sensitive) to invoke that specific agent.`; } const runSubagentToolData: IToolData = { - id: RunSubagentToolId, + id: RunSubagentTool.Id, toolReferenceName: VSCodeToolReference.runSubagent, icon: ThemeIcon.fromId(Codicon.organization.id), displayName: localize('tool.runSubagent.displayName', 'Run Subagent'), @@ -194,7 +194,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized' || part.kind === 'textEdit' || part.kind === 'notebookEdit' || part.kind === 'codeblockUri') { if (part.kind === 'codeblockUri' && !inEdit) { inEdit = true; - model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n'), fromSubagent: true }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n') }); } model.acceptResponseProgress(request, part); @@ -204,7 +204,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } else if (part.kind === 'markdownContent') { if (inEdit) { - model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n'), fromSubagent: true }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n') }); inEdit = false; } @@ -215,7 +215,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { }; if (modeTools) { - modeTools[RunSubagentToolId] = false; + modeTools[RunSubagentTool.Id] = false; modeTools[ManageTodoListToolToolId] = false; } @@ -229,7 +229,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { message: args.prompt, variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, - isSubagent: true, + subAgentInvocationId: invocation.chatStreamToolCallId, userSelectedModelId: modeModelId, userSelectedTools: modeTools, modeInstructions, @@ -249,7 +249,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return createToolSimpleTextResult(`Agent error: ${result.errorDetails.message}`); } - return createToolSimpleTextResult(markdownParts.join('') || 'Agent completed with no output'); + const resultText = markdownParts.join('') || 'Agent completed with no output'; + + // Store result in toolSpecificData for serialization + if (invocation.toolSpecificData?.kind === 'subagent') { + invocation.toolSpecificData.result = resultText; + } + + return createToolSimpleTextResult(resultText); } catch (error) { const errorMessage = `Error invoking subagent: ${error instanceof Error ? error.message : 'Unknown error'}`; @@ -263,6 +270,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return { invocationMessage: args.description, + toolSpecificData: { + kind: 'subagent', + description: args.description, + agentName: args.agentName, + prompt: args.prompt, + }, }; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index a94d97745c1..61b4ac04b28 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -24,7 +24,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; import { IVariableReference } from '../chatModes.js'; -import { IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; +import { IChatExtensionsContent, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; import { LanguageModelPartAudience } from '../languageModels.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './promptTsxTypes.js'; @@ -155,8 +155,8 @@ export interface IToolInvocation { /** * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups */ - fromSubAgent?: boolean; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + subAgentInvocationId?: string; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; modelId?: string; userSelectedTools?: UserSelectedTools; } @@ -324,7 +324,7 @@ export interface IPreparedToolInvocation { originMessage?: string | IMarkdownString; confirmationMessages?: IToolConfirmationMessages; presentation?: ToolInvocationPresentation; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; } export interface IToolImpl { @@ -392,7 +392,7 @@ export interface IBeginToolCallOptions { toolId: string; chatRequestId?: string; sessionResource?: URI; - fromSubAgent?: boolean; + subagentInvocationId?: string; } export const ILanguageModelToolsService = createDecorator('ILanguageModelToolsService'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 114f666d135..bacf032abd9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -176,8 +176,8 @@ suite('Agent Sessions', () => { test('should handle session with all properties', async () => { return runWithFakedTimers({}, async () => { - const startTime = Date.now(); - const endTime = startTime + 1000; + const created = Date.now(); + const lastRequestEnded = created + 1000; const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', @@ -190,8 +190,8 @@ suite('Agent Sessions', () => { status: ChatSessionStatus.Completed, tooltip: 'Session tooltip', iconPath: ThemeIcon.fromId('check'), - timing: { startTime, endTime }, - changes: { files: 1, insertions: 10, deletions: 5, details: [] } + timing: { created, lastRequestStarted: created, lastRequestEnded }, + changes: { files: 1, insertions: 10, deletions: 5 } } ] }; @@ -210,8 +210,8 @@ suite('Agent Sessions', () => { assert.strictEqual(session.description.value, '**Bold** description'); } assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.startTime, startTime); - assert.strictEqual(session.timing.endTime, endTime); + assert.strictEqual(session.timing.created, created); + assert.strictEqual(session.timing.lastRequestEnded, lastRequestEnded); assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 }); }); }); @@ -1521,9 +1521,10 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing before the READ_STATE_INITIAL_DATE (December 8, 2025) - const oldSessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 10 /* November */, 2), + const oldSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), }; const provider: IChatSessionItemProvider = { @@ -1552,9 +1553,10 @@ suite('Agent Sessions', () => { test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing after the READ_STATE_INITIAL_DATE (December 8, 2025) - const newSessionTiming = { - startTime: Date.UTC(2025, 11 /* December */, 10), - endTime: Date.UTC(2025, 11 /* December */, 11), + const newSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 11 /* December */, 10), + lastRequestStarted: Date.UTC(2025, 11 /* December */, 10), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 11), }; const provider: IChatSessionItemProvider = { @@ -1583,9 +1585,10 @@ suite('Agent Sessions', () => { test('should use endTime for read state comparison when available', async () => { return runWithFakedTimers({}, async () => { // Session with startTime before initial date but endTime after - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 11 /* December */, 10), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 10), }; const provider: IChatSessionItemProvider = { @@ -1606,7 +1609,7 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use endTime (December 10) which is after the initial date + // Should use lastRequestEnded (December 10) which is after the initial date assert.strictEqual(session.isRead(), false); }); }); @@ -1614,8 +1617,10 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { // Session with only startTime before initial date - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: undefined, }; const provider: IChatSessionItemProvider = { @@ -2054,8 +2059,15 @@ function makeSimpleSessionItem(id: string, overrides?: Partial }; } -function makeNewSessionTiming(): IChatSessionItem['timing'] { +function makeNewSessionTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); return { - startTime: Date.now(), + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index f29f8f83327..d551277757b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -36,8 +36,9 @@ suite('AgentSessionsDataSource', () => { label: `Session ${overrides.id ?? 'default'}`, icon: Codicon.terminal, timing: { - startTime: overrides.startTime ?? now, - endTime: overrides.endTime ?? now, + created: overrides.startTime ?? now, + lastRequestEnded: undefined, + lastRequestStarted: undefined, }, isArchived: () => overrides.isArchived ?? false, setArchived: () => { }, @@ -73,8 +74,8 @@ suite('AgentSessionsDataSource', () => { return { compare: (a, b) => { // Sort by end time, most recent first - const aTime = a.timing.endTime || a.timing.startTime; - const bTime = b.timing.endTime || b.timing.startTime; + const aTime = a.timing.lastRequestEnded ?? a.timing.lastRequestStarted ?? a.timing.created; + const bTime = b.timing.lastRequestEnded ?? b.timing.lastRequestStarted ?? b.timing.created; return bTime - aTime; } }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 7be0701efe2..8b88eae7f82 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -18,11 +18,24 @@ import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/loca import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; +function createTestTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); + return { + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, + }; +} + class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; @@ -319,7 +332,7 @@ suite('LocalAgentsSessionsProvider', () => { title: 'Test Session', lastMessageDate: Date.now(), isActive: true, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), lastResponseState: ResponseModelState.Complete }]); @@ -343,7 +356,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -369,7 +382,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); mockChatService.setHistorySessionItems([{ sessionResource, @@ -377,7 +390,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -405,7 +418,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -435,7 +448,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -464,7 +477,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -493,7 +506,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -537,7 +550,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), stats: { added: 30, removed: 8, @@ -582,7 +595,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -593,7 +606,7 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Session Timing', () => { - test('should use model timestamp for startTime when model exists', async () => { + test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -612,16 +625,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: modelTimestamp } + timing: createTestTiming({ created: modelTimestamp }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); + assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); }); - test('should use lastMessageDate for startTime when model does not exist', async () => { + test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -635,16 +648,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: lastMessageDate } + timing: createTestTiming({ created: lastMessageDate }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); + assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); }); - test('should set endTime from last response completedAt', async () => { + test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -664,12 +677,12 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: completedAt } + timing: createTestTiming({ lastRequestEnded: completedAt }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.endTime, completedAt); + assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); }); }); @@ -692,7 +705,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index bcf006ffa27..d008e7e89a8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -13,7 +13,7 @@ import { IChatEntitlementService, ChatEntitlement } from '../../../../../service import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IStringDictionary } from '../../../../../../base/common/collections.js'; -import { ILanguageModelsConfigurationService } from '../../../common/languageModelsConfiguration.js'; +import { ILanguageModelsConfigurationService, ILanguageModelsProviderGroup } from '../../../common/languageModelsConfiguration.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ChatAgentLocation } from '../../../common/constants.js'; @@ -119,6 +119,8 @@ class MockLanguageModelsService implements ILanguageModelsService { async removeLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise { } + + async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } } class MockChatEntitlementService implements IChatEntitlementService { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts new file mode 100644 index 00000000000..5eca5ccea5b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-restricted-syntax */ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { renderFileWidgets } from '../../../../browser/widget/chatContentParts/chatInlineAnchorWidget.js'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { IChatMarkdownAnchorService } from '../../../../browser/widget/chatContentParts/chatMarkdownAnchorService.js'; + +suite('ChatInlineAnchorWidget Metadata Validation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let disposables: DisposableStore; + let instantiationService: ReturnType; + let mockAnchorService: IChatMarkdownAnchorService; + + setup(() => { + disposables = store.add(new DisposableStore()); + instantiationService = workbenchInstantiationService(undefined, store); + + // Mock the anchor service + mockAnchorService = { + _serviceBrand: undefined, + register: () => ({ dispose: () => { } }), + lastFocusedAnchor: undefined + }; + + instantiationService.stub(IChatMarkdownAnchorService, mockAnchorService); + }); + + function createTestElement(linkText: string, href: string = 'file:///test.txt'): HTMLElement { + const container = mainWindow.document.createElement('div'); + const anchor = mainWindow.document.createElement('a'); + anchor.textContent = linkText; + anchor.setAttribute('data-href', href); + container.appendChild(anchor); + return container; + } + + test('renders widget for link with vscodeLinkType query parameter', () => { + const element = createTestElement('mySkill', 'file:///test.txt?vscodeLinkType=skill'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for link with vscodeLinkType query parameter'); + }); + + test('renders widget for empty link text', () => { + const element = createTestElement(''); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for empty link text'); + }); + + test('renders widget for vscodeLinkType=file', () => { + const element = createTestElement('document.txt', 'file:///path/to/document.txt?vscodeLinkType=file'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for vscodeLinkType=file'); + }); + + test('does not render widget for link without vscodeLinkType query parameter', () => { + const element = createTestElement('regular link text', 'file:///test.txt'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(!widget, 'Widget should not be rendered for link without vscodeLinkType query parameter'); + }); + + test('does not render widget when URI scheme is missing', () => { + const element = createTestElement('mySkill', ''); // Empty href + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(!widget, 'Widget should not be rendered when URI scheme is missing'); + }); + + test('renders widget with various vscodeLinkType values', () => { + const element = createTestElement('customName', 'file:///test.txt?vscodeLinkType=custom'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for any vscodeLinkType value'); + }); + + test('handles vscodeLinkType with other query parameters', () => { + const element = createTestElement('skillName', 'file:///test.txt?other=value&vscodeLinkType=skill&another=param'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered when vscodeLinkType is among multiple query parameters'); + }); + + test('handles multiple links in same element', () => { + const container = mainWindow.document.createElement('div'); + + // Add link with vscodeLinkType query parameter + const validAnchor = mainWindow.document.createElement('a'); + validAnchor.textContent = 'validSkill'; + validAnchor.setAttribute('data-href', 'file:///valid.txt?vscodeLinkType=skill'); + container.appendChild(validAnchor); + + // Add link without vscodeLinkType query parameter + const invalidAnchor = mainWindow.document.createElement('a'); + invalidAnchor.textContent = 'regular text'; + invalidAnchor.setAttribute('data-href', 'file:///invalid.txt'); + container.appendChild(invalidAnchor); + + // Add empty link text + const emptyAnchor = mainWindow.document.createElement('a'); + emptyAnchor.textContent = ''; + emptyAnchor.setAttribute('data-href', 'file:///empty.txt'); + container.appendChild(emptyAnchor); + + renderFileWidgets(container, instantiationService, mockAnchorService, disposables); + + const widgets = container.querySelectorAll('.chat-inline-anchor-widget'); + assert.strictEqual(widgets.length, 2, 'Should render widgets for link with vscodeLinkType and empty link text only'); + }); + + test('uses link text as fileName in metadata', () => { + const element = createTestElement('myCustomFileName', 'file:///test.txt?vscodeLinkType=skill'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered'); + // The link text becomes the fileName which is used as the label + const labelElement = widget?.querySelector('.icon-label'); + assert.ok(labelElement?.textContent?.includes('myCustomFileName'), 'Label should contain the link text as fileName'); + }); + + test('does not render widget for malformed URI', () => { + const element = createTestElement('mySkill', '://malformed-uri-without-scheme'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(!widget, 'Widget should not be rendered for malformed URI'); + }); +}); + diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index f38801abec3..8b60a218291 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -9,6 +9,7 @@ import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IChatMessage, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; +import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; export class NullLanguageModelsService implements ILanguageModelsService { _serviceBrand: undefined; @@ -74,4 +75,6 @@ export class NullLanguageModelsService implements ILanguageModelsService { async removeLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise { } + + async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } } diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 4cced4a16c4..026c88b2fa5 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,13 +10,14 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; sessionId = ''; readonly timestamp = 0; - readonly timing = { startTime: 0 }; + readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts index 98302f3211c..36f585c0877 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts @@ -9,6 +9,14 @@ import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js' import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { IConfigurationOverrides, IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { IPromptSourceFolder } from '../../../../common/promptSyntax/config/promptFileLocations.js'; + +/** + * Helper to extract just the paths from IPromptSourceFolder array for testing. + */ +function getPaths(folders: IPromptSourceFolder[]): string[] { + return folders.map(f => f.path); +} /** * Mocked instance of {@link IConfigurationService}. @@ -22,7 +30,7 @@ function createMock(value: T): IConfigurationService { ); assert( - [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY].includes(key), + [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY, PromptsConfig.SKILLS_LOCATION_KEY].includes(key), `Unsupported configuration key '${key}'.`, ); @@ -55,6 +63,26 @@ suite('PromptsConfig', () => { ); }); + test('undefined for skill', () => { + const configService = createMock(undefined); + + assert.strictEqual( + PromptsConfig.getLocationsValue(configService, PromptsType.skill), + undefined, + 'Must read correct value for skills.', + ); + }); + + test('null for skill', () => { + const configService = createMock(null); + + assert.strictEqual( + PromptsConfig.getLocationsValue(configService, PromptsType.skill), + undefined, + 'Must read correct value for skills.', + ); + }); + suite('object', () => { test('empty', () => { assert.deepStrictEqual( @@ -157,6 +185,50 @@ suite('PromptsConfig', () => { 'Must read correct value.', ); }); + + test('skill locations - empty', () => { + assert.deepStrictEqual( + PromptsConfig.getLocationsValue(createMock({}), PromptsType.skill), + {}, + 'Must read correct value for skills.', + ); + }); + + test('skill locations - valid paths', () => { + assert.deepStrictEqual( + PromptsConfig.getLocationsValue(createMock({ + '.github/skills': true, + '.claude/skills': true, + '/custom/skills/folder': true, + './relative/skills': true, + }), PromptsType.skill), + { + '.github/skills': true, + '.claude/skills': true, + '/custom/skills/folder': true, + './relative/skills': true, + }, + 'Must read correct skill locations.', + ); + }); + + test('skill locations - filters invalid entries', () => { + assert.deepStrictEqual( + PromptsConfig.getLocationsValue(createMock({ + '.github/skills': true, + '.claude/skills': '\t\n', + '/invalid/path': '', + '': true, + './valid/skills': true, + '\n': true, + }), PromptsType.skill), + { + '.github/skills': true, + './valid/skills': true, + }, + 'Must filter invalid skill locations.', + ); + }); }); }); @@ -165,7 +237,7 @@ suite('PromptsConfig', () => { const configService = createMock(undefined); assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(configService, PromptsType.prompt), + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.prompt)), [], 'Must read correct value.', ); @@ -175,7 +247,7 @@ suite('PromptsConfig', () => { const configService = createMock(null); assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(configService, PromptsType.prompt), + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.prompt)), [], 'Must read correct value.', ); @@ -184,7 +256,7 @@ suite('PromptsConfig', () => { suite('object', () => { test('empty', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({}), PromptsType.prompt), + getPaths(PromptsConfig.promptSourceFolders(createMock({}), PromptsType.prompt)), ['.github/prompts'], 'Must read correct value.', ); @@ -192,7 +264,7 @@ suite('PromptsConfig', () => { test('only valid strings', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/root/.bashrc': true, '../../folder/.hidden-folder/config.xml': true, '/srv/www/Public_html/.htaccess': true, @@ -206,7 +278,7 @@ suite('PromptsConfig', () => { '/var/logs/app.01.05.error': true, '.GitHub/prompts': true, './.tempfile': true, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '.github/prompts', '/root/.bashrc', @@ -229,7 +301,7 @@ suite('PromptsConfig', () => { test('filters out non valid entries', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/etc/hosts.backup': '\t\n\t', './run.tests.sh': '\v', '../assets/img/logo.v2.png': true, @@ -254,7 +326,7 @@ suite('PromptsConfig', () => { '\f\f': true, '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': 2345, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '.github/prompts', '../assets/img/logo.v2.png', @@ -271,7 +343,7 @@ suite('PromptsConfig', () => { test('only invalid or false values', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/etc/hosts.backup': '\t\n\t', './run.tests.sh': '\v', '../assets/IMG/logo.v2.png': '', @@ -282,7 +354,7 @@ suite('PromptsConfig', () => { '/var/data/datafile.2025-02-05.json': '\n', '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': 7654, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '.github/prompts', ], @@ -292,7 +364,7 @@ suite('PromptsConfig', () => { test('filters out disabled default location', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/etc/hosts.backup': '\t\n\t', './run.tests.sh': '\v', '.github/prompts': false, @@ -317,7 +389,7 @@ suite('PromptsConfig', () => { '\f\f': true, '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': 853, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '../assets/img/logo.v2.png', '../.local/bin/script.sh', @@ -331,5 +403,126 @@ suite('PromptsConfig', () => { ); }); }); + + suite('skills', () => { + test('undefined returns empty array', () => { + const configService = createMock(undefined); + + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.skill)), + [], + 'Must return empty array for undefined config.', + ); + }); + + test('null returns empty array', () => { + const configService = createMock(null); + + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.skill)), + [], + 'Must return empty array for null config.', + ); + }); + + test('empty object returns default skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({}), PromptsType.skill)), + ['.github/skills', '.claude/skills', '~/.copilot/skills', '~/.claude/skills'], + 'Must return default skill folders.', + ); + }); + + test('includes custom skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '/custom/skills': true, + './local/skills': true, + }), PromptsType.skill)), + [ + '.github/skills', + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/custom/skills', + './local/skills', + ], + 'Must include custom skill folders.', + ); + }); + + test('filters out disabled default skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '.github/skills': false, + '/custom/skills': true, + }), PromptsType.skill)), + [ + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/custom/skills', + ], + 'Must filter out disabled .github/skills folder.', + ); + }); + + test('filters out all disabled default skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + '/only/custom/skills': true, + }), PromptsType.skill)), + [ + '/only/custom/skills', + ], + 'Must filter out all disabled default folders.', + ); + }); + + test('filters out invalid entries', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '/valid/skills': true, + '/invalid/path': '\t\n', + '': true, + './another/valid': true, + '\n': true, + }), PromptsType.skill)), + [ + '.github/skills', + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/valid/skills', + './another/valid', + ], + 'Must filter out invalid entries.', + ); + }); + + test('includes all default folders when explicitly enabled', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '.github/skills': true, + '.claude/skills': true, + '~/.copilot/skills': true, + '~/.claude/skills': true, + '/extra/skills': true, + }), PromptsType.skill)), + [ + '.github/skills', + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/extra/skills', + ], + 'Must include all default folders.', + ); + }); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts index 38d354c19a0..fb2852ca71d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { getPromptFileType, getCleanPromptName } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { getPromptFileType, getCleanPromptName, isPromptOrInstructionsFile } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; suite('promptFileLocations', function () { @@ -62,6 +62,21 @@ suite('promptFileLocations', function () { const uri = URI.file('/workspace/README.md'); assert.strictEqual(getPromptFileType(uri), undefined); }); + + test('SKILL.md (uppercase) should be recognized as skill', () => { + const uri = URI.file('/workspace/.github/skills/test/SKILL.md'); + assert.strictEqual(getPromptFileType(uri), PromptsType.skill); + }); + + test('skill.md (lowercase) should be recognized as skill', () => { + const uri = URI.file('/workspace/.github/skills/test/skill.md'); + assert.strictEqual(getPromptFileType(uri), PromptsType.skill); + }); + + test('Skill.md (mixed case) should be recognized as skill', () => { + const uri = URI.file('/workspace/.github/skills/test/Skill.md'); + assert.strictEqual(getPromptFileType(uri), PromptsType.skill); + }); }); suite('getCleanPromptName', () => { @@ -104,5 +119,38 @@ suite('promptFileLocations', function () { const uri = URI.file('/workspace/test.txt'); assert.strictEqual(getCleanPromptName(uri), 'test.txt'); }); + + test('removes .md extension for SKILL.md (uppercase)', () => { + const uri = URI.file('/workspace/.github/skills/test/SKILL.md'); + assert.strictEqual(getCleanPromptName(uri), 'SKILL'); + }); + + test('removes .md extension for skill.md (lowercase)', () => { + const uri = URI.file('/workspace/.github/skills/test/skill.md'); + assert.strictEqual(getCleanPromptName(uri), 'skill'); + }); + + test('removes .md extension for Skill.md (mixed case)', () => { + const uri = URI.file('/workspace/.github/skills/test/Skill.md'); + assert.strictEqual(getCleanPromptName(uri), 'Skill'); + }); + }); + + suite('isPromptOrInstructionsFile', () => { + test('SKILL.md files should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/skills/test/SKILL.md')), true); + }); + + test('skill.md (lowercase) should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/skills/myskill/skill.md')), true); + }); + + test('Skill.md (mixed case) should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/skills/Skill.md')), true); + }); + + test('regular .md files should return false', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/SKILL2.md')), false); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index f71149b1218..c46dd40f5b0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -39,7 +39,7 @@ export class MockPromptsService implements IPromptsService { listPromptFiles(_type: any): Promise { throw new Error('Not implemented'); } listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getSourceFolders(_type: any): readonly any[] { throw new Error('Not implemented'); } + getSourceFolders(_type: any): Promise { throw new Error('Not implemented'); } isValidSlashCommandName(_command: string): boolean { return false; } // eslint-disable-next-line @typescript-eslint/no-explicit-any resolvePromptSlashCommand(command: string, _token: CancellationToken): Promise { throw new Error('Not implemented'); } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 55845c7bcc1..230ace54a1d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -6,8 +6,10 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; +import { relativePath } from '../../../../../../../base/common/resources.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; @@ -41,7 +43,7 @@ import { PromptsService } from '../../../../common/promptSyntax/service/promptsS import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; import { IPathService } from '../../../../../../services/path/common/pathService.js'; -import { ISearchService } from '../../../../../../services/search/common/search.js'; +import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; import { IDefaultAccountService } from '../../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IDefaultAccount } from '../../../../../../../base/common/defaultAccount.js'; @@ -116,7 +118,38 @@ suite('PromptsService', () => { } as IPathService; instaService.stub(IPathService, pathService); - instaService.stub(ISearchService, {}); + instaService.stub(ISearchService, { + async fileSearch(query: IFileQuery) { + // mock the search service - recursively find files matching pattern + const findFilesInLocation = async (location: URI, results: URI[] = []): Promise => { + try { + const resolve = await fileService.resolve(location); + if (resolve.isFile) { + results.push(resolve.resource); + } else if (resolve.isDirectory && resolve.children) { + for (const child of resolve.children) { + await findFilesInLocation(child.resource, results); + } + } + } catch (error) { + // folder doesn't exist + } + return results; + }; + + const results: IFileMatch[] = []; + for (const folderQuery of query.folderQueries) { + const allFiles = await findFilesInLocation(folderQuery.folder); + for (const resource of allFiles) { + const pathInFolder = relativePath(folderQuery.folder, resource) ?? ''; + if (query.filePattern === undefined || match(query.filePattern, pathInFolder)) { + results.push({ resource }); + } + } + } + return { results, messages: [] }; + } + }); service = disposables.add(instaService.createInstance(PromptsService)); instaService.stub(IPromptsService, service); @@ -1060,6 +1093,264 @@ suite('PromptsService', () => { }); }); + suite('listPromptFiles - skills', () => { + teardown(() => { + sinon.restore(); + }); + + test('should list skill files from workspace', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'list-skills-workspace'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/skill1/SKILL.md`, + contents: [ + '---', + 'name: "Skill 1"', + 'description: "First skill"', + '---', + 'Skill 1 content', + ], + }, + { + path: `${rootFolder}/.claude/skills/skill2/SKILL.md`, + contents: [ + '---', + 'name: "Skill 2"', + 'description: "Second skill"', + '---', + 'Skill 2 content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 2, 'Should find 2 skills'); + + const skill1 = result.find(s => s.uri.path.includes('skill1')); + assert.ok(skill1, 'Should find skill1'); + assert.strictEqual(skill1.type, PromptsType.skill); + assert.strictEqual(skill1.storage, PromptsStorage.local); + + const skill2 = result.find(s => s.uri.path.includes('skill2')); + assert.ok(skill2, 'Should find skill2'); + assert.strictEqual(skill2.type, PromptsType.skill); + assert.strictEqual(skill2.storage, PromptsStorage.local); + }); + + test('should list skill files from user home', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'list-skills-user-home'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: "Personal Skill"', + 'description: "A personal skill"', + '---', + 'Personal skill content', + ], + }, + { + path: '/home/user/.claude/skills/claude-personal/SKILL.md', + contents: [ + '---', + 'name: "Claude Personal Skill"', + 'description: "A Claude personal skill"', + '---', + 'Claude personal skill content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + const personalSkills = result.filter(s => s.storage === PromptsStorage.user); + assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); + + const copilotSkill = personalSkills.find(s => s.uri.path.includes('.copilot')); + assert.ok(copilotSkill, 'Should find copilot personal skill'); + + const claudeSkill = personalSkills.find(s => s.uri.path.includes('.claude')); + assert.ok(claudeSkill, 'Should find claude personal skill'); + }); + + test('should not list skills when not in skill folder structure', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + const rootFolderName = 'no-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create files in non-skill locations + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/prompts/SKILL.md`, + contents: [ + '---', + 'name: "Not a skill"', + '---', + 'This is in prompts folder, not skills', + ], + }, + { + path: `${rootFolder}/SKILL.md`, + contents: [ + '---', + 'name: "Root skill"', + '---', + 'This is in root, not skills folder', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 0, 'Should not find any skills in non-skill locations'); + }); + + test('should handle mixed workspace and user home skills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'mixed-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + // Workspace skills + { + path: `${rootFolder}/.github/skills/workspace-skill/SKILL.md`, + contents: [ + '---', + 'name: "Workspace Skill"', + 'description: "A workspace skill"', + '---', + 'Workspace skill content', + ], + }, + // User home skills + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: "Personal Skill"', + 'description: "A personal skill"', + '---', + 'Personal skill content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + const workspaceSkills = result.filter(s => s.storage === PromptsStorage.local); + const userSkills = result.filter(s => s.storage === PromptsStorage.user); + + assert.strictEqual(workspaceSkills.length, 1, 'Should find 1 workspace skill'); + assert.strictEqual(userSkills.length, 1, 'Should find 1 user skill'); + }); + + test('should respect disabled default paths via config', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + // Disable .github/skills, only .claude/skills should be searched + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { + '.github/skills': false, + '.claude/skills': true, + }); + + const rootFolderName = 'disabled-default-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/github-skill/SKILL.md`, + contents: [ + '---', + 'name: "GitHub Skill"', + 'description: "Should NOT be found"', + '---', + 'This skill is in a disabled folder', + ], + }, + { + path: `${rootFolder}/.claude/skills/claude-skill/SKILL.md`, + contents: [ + '---', + 'name: "Claude Skill"', + 'description: "Should be found"', + '---', + 'This skill is in an enabled folder', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 1, 'Should find only 1 skill (from enabled folder)'); + assert.ok(result[0].uri.path.includes('.claude/skills'), 'Should only find skill from .claude/skills'); + assert.ok(!result[0].uri.path.includes('.github/skills'), 'Should not find skill from disabled .github/skills'); + }); + + test('should expand tilde paths in custom locations', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + // Add a tilde path as custom location + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { + '.github/skills': false, + '.claude/skills': false, + '~/my-custom-skills': true, + }); + + const rootFolderName = 'tilde-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // The mock user home is /home/user, so ~/my-custom-skills should resolve to /home/user/my-custom-skills + await mockFiles(fileService, [ + { + path: '/home/user/my-custom-skills/custom-skill/SKILL.md', + contents: [ + '---', + 'name: "Custom Skill"', + 'description: "A skill from tilde path"', + '---', + 'Skill content from ~/my-custom-skills', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 1, 'Should find 1 skill from tilde-expanded path'); + assert.ok(result[0].uri.path.includes('/home/user/my-custom-skills'), 'Path should be expanded from tilde'); + }); + }); + suite('listPromptFiles - extensions', () => { test('Contributed prompt file', async () => { @@ -1466,6 +1757,125 @@ suite('PromptsService', () => { registered.dispose(); }); + test('Skill file provider', async () => { + const skillUri = URI.parse('file://extensions/my-extension/mySkill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the skill file content + await mockFiles(fileService, [ + { + path: skillUri.path, + contents: [ + '---', + 'name: "My Custom Skill"', + 'description: "A custom skill from provider"', + '---', + 'Custom skill content.', + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: skillUri + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + + const actual = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + const providerSkill = actual.find(i => i.uri.toString() === skillUri.toString()); + + assert.ok(providerSkill, 'Provider skill should be found'); + assert.strictEqual(providerSkill!.uri.toString(), skillUri.toString()); + assert.strictEqual(providerSkill!.storage, PromptsStorage.extension); + assert.strictEqual(providerSkill!.source, ExtensionAgentSourceType.provider); + + registered.dispose(); + + // After disposal, the skill should no longer be listed + const actualAfterDispose = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + const foundAfterDispose = actualAfterDispose.find(i => i.uri.toString() === skillUri.toString()); + assert.strictEqual(foundAfterDispose, undefined); + }); + + test('Skill file provider with isEditable flag', async () => { + const readonlySkillUri = URI.parse('file://extensions/my-extension/readonlySkill/SKILL.md'); + const editableSkillUri = URI.parse('file://extensions/my-extension/editableSkill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the skill file content + await mockFiles(fileService, [ + { + path: readonlySkillUri.path, + contents: [ + '---', + 'name: "Readonly Skill"', + 'description: "A readonly skill"', + '---', + 'Readonly skill content.', + ] + }, + { + path: editableSkillUri.path, + contents: [ + '---', + 'name: "Editable Skill"', + 'description: "An editable skill"', + '---', + 'Editable skill content.', + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: readonlySkillUri, + isEditable: false + }, + { + uri: editableSkillUri, + isEditable: true + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + + // Spy on updateReadonly to verify it's called correctly + const filesConfigService = instaService.get(IFilesConfigurationService); + const updateReadonlySpy = sinon.spy(filesConfigService, 'updateReadonly'); + + // List prompt files to trigger the readonly check + await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + // Verify updateReadonly was called only for the non-editable skill + assert.strictEqual(updateReadonlySpy.callCount, 1, 'updateReadonly should be called once'); + assert.ok(updateReadonlySpy.calledWith(readonlySkillUri, true), 'updateReadonly should be called with readonly skill URI and true'); + + const actual = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + const readonlySkill = actual.find(i => i.uri.toString() === readonlySkillUri.toString()); + const editableSkill = actual.find(i => i.uri.toString() === editableSkillUri.toString()); + + assert.ok(readonlySkill, 'Readonly skill should be found'); + assert.ok(editableSkill, 'Editable skill should be found'); + + registered.dispose(); + }); + suite('findAgentSkills', () => { teardown(() => { sinon.restore(); @@ -1516,6 +1926,7 @@ suite('PromptsService', () => { test('should find skills in workspace and user home', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'agent-skills-test'; const rootFolder = `/${rootFolderName}`; @@ -1524,9 +1935,10 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); // Create mock filesystem with skills in both .github/skills and .claude/skills + // Folder names must match the skill names exactly (per agentskills.io specification) await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/github-skill-1/SKILL.md`, + path: `${rootFolder}/.github/skills/GitHub Skill 1/SKILL.md`, contents: [ '---', 'name: "GitHub Skill 1"', @@ -1536,7 +1948,7 @@ suite('PromptsService', () => { ], }, { - path: `${rootFolder}/.claude/skills/claude-skill-1/SKILL.md`, + path: `${rootFolder}/.claude/skills/Claude Skill 1/SKILL.md`, contents: [ '---', 'name: "Claude Skill 1"', @@ -1559,7 +1971,7 @@ suite('PromptsService', () => { contents: ['This is not a skill'], }, { - path: '/home/user/.claude/skills/personal-skill-1/SKILL.md', + path: '/home/user/.claude/skills/Personal Skill 1/SKILL.md', contents: [ '---', 'name: "Personal Skill 1"', @@ -1573,7 +1985,7 @@ suite('PromptsService', () => { contents: ['Not a skill file'], }, { - path: '/home/user/.copilot/skills/copilot-skill-1/SKILL.md', + path: '/home/user/.copilot/skills/Copilot Skill 1/SKILL.md', contents: [ '---', 'name: "Copilot Skill 1"', @@ -1590,36 +2002,37 @@ suite('PromptsService', () => { assert.strictEqual(result.length, 4, 'Should find 4 skills total'); // Check project skills (both from .github/skills and .claude/skills) - const projectSkills = result.filter(skill => skill.type === 'project'); + const projectSkills = result.filter(skill => skill.storage === PromptsStorage.local); assert.strictEqual(projectSkills.length, 2, 'Should find 2 project skills'); const githubSkill1 = projectSkills.find(skill => skill.name === 'GitHub Skill 1'); assert.ok(githubSkill1, 'Should find GitHub skill 1'); assert.strictEqual(githubSkill1.description, 'A GitHub skill for testing'); - assert.strictEqual(githubSkill1.uri.path, `${rootFolder}/.github/skills/github-skill-1/SKILL.md`); + assert.strictEqual(githubSkill1.uri.path, `${rootFolder}/.github/skills/GitHub Skill 1/SKILL.md`); const claudeSkill1 = projectSkills.find(skill => skill.name === 'Claude Skill 1'); assert.ok(claudeSkill1, 'Should find Claude skill 1'); assert.strictEqual(claudeSkill1.description, 'A Claude skill for testing'); - assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/claude-skill-1/SKILL.md`); + assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/Claude Skill 1/SKILL.md`); // Check personal skills - const personalSkills = result.filter(skill => skill.type === 'personal'); + const personalSkills = result.filter(skill => skill.storage === PromptsStorage.user); assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); const personalSkill1 = personalSkills.find(skill => skill.name === 'Personal Skill 1'); assert.ok(personalSkill1, 'Should find Personal Skill 1'); assert.strictEqual(personalSkill1.description, 'A personal skill for testing'); - assert.strictEqual(personalSkill1.uri.path, '/home/user/.claude/skills/personal-skill-1/SKILL.md'); + assert.strictEqual(personalSkill1.uri.path, '/home/user/.claude/skills/Personal Skill 1/SKILL.md'); const copilotSkill1 = personalSkills.find(skill => skill.name === 'Copilot Skill 1'); assert.ok(copilotSkill1, 'Should find Copilot Skill 1'); assert.strictEqual(copilotSkill1.description, 'A Copilot skill for testing'); - assert.strictEqual(copilotSkill1.uri.path, '/home/user/.copilot/skills/copilot-skill-1/SKILL.md'); + assert.strictEqual(copilotSkill1.uri.path, '/home/user/.copilot/skills/Copilot Skill 1/SKILL.md'); }); test('should handle parsing errors gracefully', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'skills-error-test'; const rootFolder = `/${rootFolderName}`; @@ -1628,9 +2041,10 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); // Create mock filesystem with malformed skill file in .github/skills + // Folder names must match the skill names exactly await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/valid-skill/SKILL.md`, + path: `${rootFolder}/.github/skills/Valid Skill/SKILL.md`, contents: [ '---', 'name: "Valid Skill"', @@ -1656,7 +2070,7 @@ suite('PromptsService', () => { assert.ok(result, 'Should return results even with parsing errors'); assert.strictEqual(result.length, 1, 'Should find 1 valid skill'); assert.strictEqual(result[0].name, 'Valid Skill'); - assert.strictEqual(result[0].type, 'project'); + assert.strictEqual(result[0].storage, PromptsStorage.local); }); test('should return empty array when no skills found', async () => { @@ -1679,6 +2093,7 @@ suite('PromptsService', () => { test('should truncate long names and descriptions', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'truncation-test'; const rootFolder = `/${rootFolderName}`; @@ -1687,11 +2102,13 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); const longName = 'A'.repeat(100); // Exceeds 64 characters + const truncatedName = 'A'.repeat(64); // Expected after truncation const longDescription = 'B'.repeat(1500); // Exceeds 1024 characters await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/long-skill/SKILL.md`, + // Folder name must match the truncated skill name + path: `${rootFolder}/.github/skills/${truncatedName}/SKILL.md`, contents: [ '---', `name: "${longName}"`, @@ -1712,6 +2129,7 @@ suite('PromptsService', () => { test('should remove XML tags from name and description', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'xml-test'; const rootFolder = `/${rootFolderName}`; @@ -1719,9 +2137,10 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + // Folder name must match the sanitized skill name (with XML tags removed) await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/xml-skill/SKILL.md`, + path: `${rootFolder}/.github/skills/Skill with XML tags/SKILL.md`, contents: [ '---', 'name: "Skill with XML tags"', @@ -1742,6 +2161,7 @@ suite('PromptsService', () => { test('should handle both truncation and XML removal', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'combined-test'; const rootFolder = `/${rootFolderName}`; @@ -1750,11 +2170,13 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); const longNameWithXml = '

' + 'A'.repeat(100) + '

'; // Exceeds 64 chars and has XML + const truncatedName = 'A'.repeat(64); // Expected after XML removal and truncation const longDescWithXml = '
' + 'B'.repeat(1500) + '
'; // Exceeds 1024 chars and has XML + // Folder name must match the fully sanitized skill name await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/combined-skill/SKILL.md`, + path: `${rootFolder}/.github/skills/${truncatedName}/SKILL.md`, contents: [ '---', `name: "${longNameWithXml}"`, @@ -1777,5 +2199,318 @@ suite('PromptsService', () => { assert.ok(!result[0].description?.includes('>'), 'Description should not contain XML tags'); assert.strictEqual(result[0].description?.length, 1024, 'Description should be truncated to 1024 characters'); }); + + test('should skip duplicate skill names and keep first by priority', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'duplicate-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create skills with duplicate names in different locations + // Workspace skill should be kept (higher priority), user skill should be skipped + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/Duplicate Skill/SKILL.md`, + contents: [ + '---', + 'name: "Duplicate Skill"', + 'description: "Workspace version"', + '---', + 'Workspace skill content', + ], + }, + { + path: '/home/user/.copilot/skills/Duplicate Skill/SKILL.md', + contents: [ + '---', + 'name: "Duplicate Skill"', + 'description: "User version - should be skipped"', + '---', + 'User skill content', + ], + }, + { + path: `${rootFolder}/.claude/skills/Unique Skill/SKILL.md`, + contents: [ + '---', + 'name: "Unique Skill"', + 'description: "A unique skill"', + '---', + 'Unique skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 2, 'Should find 2 skills (duplicate skipped)'); + + const duplicateSkill = result.find(s => s.name === 'Duplicate Skill'); + assert.ok(duplicateSkill, 'Should find the duplicate skill'); + assert.strictEqual(duplicateSkill.description, 'Workspace version', 'Should keep workspace version (higher priority)'); + assert.strictEqual(duplicateSkill.storage, PromptsStorage.local, 'Should be from workspace'); + + const uniqueSkill = result.find(s => s.name === 'Unique Skill'); + assert.ok(uniqueSkill, 'Should find the unique skill'); + }); + + test('should prioritize skills by source: workspace > user > extension', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'priority-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create skills from different sources with same name + await mockFiles(fileService, [ + { + path: '/home/user/.copilot/skills/Priority Skill/SKILL.md', + contents: [ + '---', + 'name: "Priority Skill"', + 'description: "User version"', + '---', + 'User skill content', + ], + }, + { + path: `${rootFolder}/.github/skills/Priority Skill/SKILL.md`, + contents: [ + '---', + 'name: "Priority Skill"', + 'description: "Workspace version - highest priority"', + '---', + 'Workspace skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 1, 'Should find 1 skill (duplicates resolved by priority)'); + assert.strictEqual(result[0].description, 'Workspace version - highest priority', 'Workspace should win over user'); + assert.strictEqual(result[0].storage, PromptsStorage.local); + }); + + test('should skip skills where name does not match folder name', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'name-mismatch-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + // Folder name "wrong-folder-name" doesn't match skill name "Correct Skill Name" + path: `${rootFolder}/.github/skills/wrong-folder-name/SKILL.md`, + contents: [ + '---', + 'name: "Correct Skill Name"', + 'description: "This skill should be skipped due to name mismatch"', + '---', + 'Skill content', + ], + }, + { + // Folder name matches skill name + path: `${rootFolder}/.github/skills/Valid Skill/SKILL.md`, + contents: [ + '---', + 'name: "Valid Skill"', + 'description: "This skill should be found"', + '---', + 'Valid skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 1, 'Should find only 1 skill (mismatched one skipped)'); + assert.strictEqual(result[0].name, 'Valid Skill', 'Should only find the valid skill'); + }); + + test('should skip skills with missing name attribute', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'missing-name-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/no-name-skill/SKILL.md`, + contents: [ + '---', + 'description: "This skill has no name attribute"', + '---', + 'Skill content without name', + ], + }, + { + path: `${rootFolder}/.github/skills/Valid Named Skill/SKILL.md`, + contents: [ + '---', + 'name: "Valid Named Skill"', + 'description: "This skill has a name"', + '---', + 'Valid skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 1, 'Should find only 1 skill (one without name skipped)'); + assert.strictEqual(result[0].name, 'Valid Named Skill', 'Should only find skill with name attribute'); + }); + + test('should include extension-provided skills in findAgentSkills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'extension-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const extensionSkillUri = URI.parse('file://extensions/my-extension/Extension Skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Create workspace skill and extension skill + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/Workspace Skill/SKILL.md`, + contents: [ + '---', + 'name: "Workspace Skill"', + 'description: "A workspace skill"', + '---', + 'Workspace skill content', + ], + }, + { + path: extensionSkillUri.path, + contents: [ + '---', + 'name: "Extension Skill"', + 'description: "A skill from extension provider"', + '---', + 'Extension skill content', + ], + }, + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [{ uri: extensionSkillUri }]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 2, 'Should find 2 skills (workspace + extension)'); + + const workspaceSkill = result.find(s => s.name === 'Workspace Skill'); + assert.ok(workspaceSkill, 'Should find workspace skill'); + assert.strictEqual(workspaceSkill.storage, PromptsStorage.local); + + const extensionSkill = result.find(s => s.name === 'Extension Skill'); + assert.ok(extensionSkill, 'Should find extension skill'); + assert.strictEqual(extensionSkill.storage, PromptsStorage.extension); + + registered.dispose(); + }); + + test('should include contributed skill files in findAgentSkills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'contributed-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const contributedSkillUri = URI.parse('file://extensions/my-extension/Contributed Skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' } + } as unknown as IExtensionDescription; + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/Local Skill/SKILL.md`, + contents: [ + '---', + 'name: "Local Skill"', + 'description: "A local skill"', + '---', + 'Local skill content', + ], + }, + { + path: contributedSkillUri.path, + contents: [ + '---', + 'name: "Contributed Skill"', + 'description: "A contributed skill from extension"', + '---', + 'Contributed skill content', + ], + }, + ]); + + const registered = service.registerContributedFile( + PromptsType.skill, + contributedSkillUri, + extension, + 'Contributed Skill', + 'A contributed skill from extension' + ); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 2, 'Should find 2 skills (local + contributed)'); + + const localSkill = result.find(s => s.name === 'Local Skill'); + assert.ok(localSkill, 'Should find local skill'); + assert.strictEqual(localSkill.storage, PromptsStorage.local); + + const contributedSkill = result.find(s => s.name === 'Contributed Skill'); + assert.ok(contributedSkill, 'Should find contributed skill'); + assert.strictEqual(contributedSkill.storage, PromptsStorage.extension); + + registered.dispose(); + + // After disposal, only local skill should remain + const resultAfterDispose = await service.findAgentSkills(CancellationToken.None); + assert.strictEqual(resultAfterDispose?.length, 1, 'Should find 1 skill after disposal'); + assert.strictEqual(resultAfterDispose?.[0].name, 'Local Skill'); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts index c8ee911524f..cb408b18a05 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts @@ -167,16 +167,17 @@ export class MockFilesystem { */ private async ensureParentDirectories(dirUri: URI): Promise { if (!await this.fileService.exists(dirUri)) { - if (dirUri.path === '/') { - try { - await this.fileService.createFolder(dirUri); - this.createdFolders.push(dirUri); - } catch (error) { - throw new Error(`Failed to create directory '${dirUri.toString()}': ${error}.`); - } - } else { + // First ensure the parent directory exists (recursive call) + if (dirUri.path !== '/') { await this.ensureParentDirectories(dirname(dirUri)); } + // Then create this directory + try { + await this.fileService.createFolder(dirUri); + this.createdFolders.push(dirUri); + } catch (error) { + throw new Error(`Failed to create directory '${dirUri.toString()}': ${error}.`); + } } } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index 884decc92dd..c983f777c3d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -21,9 +21,10 @@ import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../.. import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js'; +import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { isValidGlob, PromptFilesLocator } from '../../../../common/promptSyntax/utils/promptFilesLocator.js'; +import { isValidGlob, isValidSkillPath, PromptFilesLocator } from '../../../../common/promptSyntax/utils/promptFilesLocator.js'; import { IMockFolder, MockFilesystem } from '../testUtils/mockFilesystem.js'; import { mockService } from './mock.js'; import { TestUserDataProfileService } from '../../../../../../test/common/workbenchTestServices.js'; @@ -45,7 +46,7 @@ function mockConfigService(value: T): IConfigurationService { } assert( - [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY].includes(key), + [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY, PromptsConfig.SKILLS_LOCATION_KEY].includes(key), `Unsupported configuration key '${key}'.`, ); @@ -149,6 +150,15 @@ suite('PromptFilesLocator', () => { return { results, messages: [] }; } }); + instantiationService.stub(IPathService, { + userHome(options?: { preferLocal: boolean }): URI | Promise { + const uri = URI.file('/Users/legomushroom'); + if (options?.preferLocal) { + return uri; + } + return Promise.resolve(uri); + } + } as IPathService); const locator = instantiationService.createInstance(PromptFilesLocator); @@ -156,8 +166,11 @@ suite('PromptFilesLocator', () => { async listFiles(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { return locator.listFiles(type, storage, token); }, - getConfigBasedSourceFolders(type: PromptsType): readonly URI[] { - return locator.getConfigBasedSourceFolders(type); + async getConfigBasedSourceFolders(type: PromptsType): Promise { + return await locator.getConfigBasedSourceFolders(type); + }, + async findAgentSkills(token: CancellationToken) { + return await locator.findAgentSkills(token); }, async disposeAsync(): Promise { await mockFs.delete(); @@ -2349,6 +2362,473 @@ suite('PromptFilesLocator', () => { }); }); + suite('skills', () => { + suite('findAgentSkills', () => { + testT('finds skill files in configured locations', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'pptx', + children: [ + { + name: 'SKILL.md', + contents: '# PPTX Skill', + }, + ], + }, + { + name: 'excel', + children: [ + { + name: 'SKILL.md', + contents: '# Excel Skill', + }, + ], + }, + ], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [ + '/Users/legomushroom/repos/vscode/.claude/skills/pptx/SKILL.md', + '/Users/legomushroom/repos/vscode/.claude/skills/excel/SKILL.md', + ], + 'Must find skill files.', + ); + await locator.disposeAsync(); + }); + + testT('ignores folders without SKILL.md', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'valid-skill', + children: [ + { + name: 'SKILL.md', + contents: '# Valid Skill', + }, + ], + }, + { + name: 'invalid-skill', + children: [ + { + name: 'readme.md', + contents: 'Not a skill file', + }, + ], + }, + { + name: 'another-invalid', + children: [ + { + name: 'index.js', + contents: 'console.log("not a skill")', + }, + ], + }, + ], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [ + '/Users/legomushroom/repos/vscode/.claude/skills/valid-skill/SKILL.md', + ], + 'Must only find folders with SKILL.md.', + ); + await locator.disposeAsync(); + }); + + testT('returns empty array when no skills exist', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [], + 'Must return empty array when no skills exist.', + ); + await locator.disposeAsync(); + }); + + testT('returns empty array when skill folder does not exist', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], // empty filesystem + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [], + 'Must return empty array when folder does not exist.', + ); + await locator.disposeAsync(); + }); + + testT('finds skills across multiple workspace folders', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + [ + '/Users/legomushroom/repos/vscode', + '/Users/legomushroom/repos/node', + ], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'skill-a', + children: [ + { + name: 'SKILL.md', + contents: '# Skill A', + }, + ], + }, + ], + }, + { + name: '/Users/legomushroom/repos/node/.claude/skills', + children: [ + { + name: 'skill-b', + children: [ + { + name: 'SKILL.md', + contents: '# Skill B', + }, + ], + }, + ], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [ + '/Users/legomushroom/repos/vscode/.claude/skills/skill-a/SKILL.md', + '/Users/legomushroom/repos/node/.claude/skills/skill-b/SKILL.md', + ], + 'Must find skills across all workspace folders.', + ); + await locator.disposeAsync(); + }); + }); + + suite('listFiles with PromptsType.skill', () => { + testT('does not list skills when location is disabled', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': false, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'pptx', + children: [ + { + name: 'SKILL.md', + contents: '# PPTX Skill', + }, + ], + }, + ], + }, + ], + ); + + const files = await locator.listFiles(PromptsType.skill, PromptsStorage.local, CancellationToken.None); + assertOutcome( + files, + [], + 'Must not list skills when location is disabled.', + ); + await locator.disposeAsync(); + }); + }); + + suite('toAbsoluteLocationsForSkills path validation', () => { + testT('rejects glob patterns in skill paths via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + 'skills/**': true, + 'skills/*': true, + '**/skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [], + 'Must reject glob patterns in skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('rejects absolute paths in skill paths via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + '/absolute/path/skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [], + 'Must reject absolute paths in skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('accepts relative paths in skill paths via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + './my-skills': true, + 'custom/skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/vscode/my-skills', + '/Users/legomushroom/repos/vscode/custom/skills', + ], + 'Must accept relative paths in skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('accepts parent relative paths for monorepos via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + '../shared-skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/shared-skills', + ], + 'Must accept parent relative paths for monorepos.', + ); + await locator.disposeAsync(); + }); + + testT('accepts tilde paths for user home skills', async () => { + const locator = await createPromptsLocator( + { + '~/my-skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/my-skills', + ], + 'Must accept tilde paths for user home skills.', + ); + await locator.disposeAsync(); + }); + }); + + suite('getConfigBasedSourceFolders for skills', () => { + testT('returns source folders without glob processing', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + 'custom-skills': true, + // explicitly disable other defaults we don't want for this test + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + [ + '/Users/legomushroom/repos/vscode', + '/Users/legomushroom/repos/node', + ], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/vscode/.claude/skills', + '/Users/legomushroom/repos/node/.claude/skills', + '/Users/legomushroom/repos/vscode/custom-skills', + '/Users/legomushroom/repos/node/custom-skills', + ], + 'Must return skill source folders without glob processing.', + ); + await locator.disposeAsync(); + }); + + testT('filters out invalid skill paths from source folders', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + 'skills/**': true, // glob - should be filtered out + '/absolute/skills': true, // absolute - should be filtered out + // explicitly disable other defaults we don't want for this test + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/vscode/.claude/skills', + ], + 'Must filter out invalid skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('includes default skill source folders from defaults', async () => { + const locator = await createPromptsLocator( + { + 'custom-skills': true, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + // defaults + '/Users/legomushroom/repos/vscode/.github/skills', + '/Users/legomushroom/repos/vscode/.claude/skills', + '/Users/legomushroom/.copilot/skills', + '/Users/legomushroom/.claude/skills', + // custom + '/Users/legomushroom/repos/vscode/custom-skills', + ], + 'Must include default skill source folders.', + ); + await locator.disposeAsync(); + }); + }); + }); + suite('isValidGlob', () => { testT('valid patterns', async () => { const globs = [ @@ -2424,6 +2904,155 @@ suite('PromptFilesLocator', () => { }); }); + suite('isValidSkillPath', () => { + testT('accepts relative paths', async () => { + const validPaths = [ + 'someFolder', + './someFolder', + 'my-skills', + './my-skills', + 'folder/subfolder', + './folder/subfolder', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted as a valid skill path (relative path).`, + ); + } + }); + + testT('accepts user home paths', async () => { + const validPaths = [ + '~/folder', + '~/.copilot/skills', + '~/.claude/skills', + '~/my-skills', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted as a valid skill path (user home path).`, + ); + } + }); + + testT('accepts parent relative paths for monorepos', async () => { + const validPaths = [ + '../folder', + '../shared-skills', + '../../common/skills', + '../parent/folder', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted as a valid skill path (parent relative path).`, + ); + } + }); + + testT('rejects absolute paths', async () => { + const invalidPaths = [ + // Unix absolute paths + '/Users/username/skills', + '/absolute/path', + '/usr/local/skills', + // Windows absolute paths + 'C:\\Users\\skills', + 'D:/skills', + 'c:\\folder', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (absolute paths not supported for portability).`, + ); + } + }); + + testT('rejects tilde paths without path separator', async () => { + const invalidPaths = [ + '~abc', + '~skills', + '~.config', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (tilde must be followed by / or \\).`, + ); + } + }); + + testT('rejects glob patterns', async () => { + const invalidPaths = [ + 'skills/*', + 'skills/**', + '**/skills', + 'skills/*.md', + 'skills/**/*.md', + '{skill1,skill2}', + 'skill[1,2,3]', + 'skills?', + './skills/*', + '~/skills/**', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (glob patterns not supported for performance).`, + ); + } + }); + + testT('rejects empty or whitespace paths', async () => { + const invalidPaths = [ + '', + ' ', + '\t', + '\n', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (empty or whitespace only).`, + ); + } + }); + + testT('handles paths with spaces', async () => { + const validPaths = [ + 'my skills', + './my skills/folder', + '~/my skills', + '../shared skills', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted (paths with spaces are valid).`, + ); + } + }); + }); + suite('getConfigBasedSourceFolders', () => { testT('gets unambiguous list of folders', async () => { const locator = await createPromptsLocator( @@ -2445,7 +3074,7 @@ suite('PromptFilesLocator', () => { ); assertOutcome( - locator.getConfigBasedSourceFolders(PromptsType.prompt), + await locator.getConfigBasedSourceFolders(PromptsType.prompt), [ '/Users/legomushroom/repos/vscode/.github/prompts', '/Users/legomushroom/repos/prompts/.github/prompts', diff --git a/src/vs/workbench/contrib/extensions/browser/media/extension.css b/src/vs/workbench/contrib/extensions/browser/media/extension.css index 9bda6c319fc..0ba9d0381a8 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extension.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extension.css @@ -269,13 +269,13 @@ /* single install */ .extension-list-item .monaco-action-bar > .actions-container > .action-item.action-dropdown-item.empty > .extension-action { - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); } /* split install */ .extension-list-item .monaco-action-bar > .actions-container > .action-item.action-dropdown-item:not(.empty) > .extension-action.label:not(.dropdown) { - border-radius: 2px 0 0 2px; + border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small); } .extension-list-item .monaco-action-bar > .actions-container > .action-item.action-dropdown-item > .monaco-dropdown .extension-action { - border-radius: 0 2px 2px 0; + border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0; } diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css index eb7649d45cb..2e1cece4685 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css @@ -131,9 +131,8 @@ .extension-editor > .header > .details > .subtitle { padding-top: 6px; - white-space: nowrap; - height: 20px; line-height: 20px; + flex-wrap: wrap; } .extension-editor > .header > .details > .subtitle .hide { @@ -179,9 +178,6 @@ .extension-editor > .header > .details > .description { margin-top: 10px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; } .extension-editor > .header > .details > .actions-status-container { @@ -197,6 +193,10 @@ text-align: initial; } +.extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container { + flex-wrap: wrap; +} + .extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container > .action-item { margin-right: 0; overflow: hidden; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 33bac6a0273..700b138b98d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -437,7 +437,7 @@ export class InlineChatController implements IEditorContribution { const persistModelChoice = this._configurationService.getValue(InlineChatConfigKeys.PersistModelChoice); const model = this._zone.value.widget.chatWidget.input.selectedLanguageModel; if (!persistModelChoice && InlineChatController._selectVendorDefaultLanguageModel && model && !model.metadata.isDefaultForLocation[session.chatModel.initialLocation]) { - const ids = await this._languageModelService.selectLanguageModels({ vendor: model.metadata.vendor }, false); + const ids = await this._languageModelService.selectLanguageModels({ vendor: model.metadata.vendor }); for (const identifier of ids) { const candidate = this._languageModelService.lookupLanguageModel(identifier); if (candidate?.isDefaultForLocation[session.chatModel.initialLocation]) { @@ -484,7 +484,7 @@ export class InlineChatController implements IEditorContribution { delete arg.attachments; } if (arg.modelSelector) { - const id = (await this._languageModelService.selectLanguageModels(arg.modelSelector, false)).sort().at(0); + const id = (await this._languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0); if (!id) { throw new Error(`No language models found matching selector: ${JSON.stringify(arg.modelSelector)}.`); } diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts index 5fad6f93177..a18ff87a5d8 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts @@ -454,6 +454,10 @@ import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '../../platform/window/electron-b 'remote-debugging-port': { type: 'string', description: localize('argv.remoteDebuggingPort', "Specifies the port to use for remote debugging.") + }, + 'enable-graphite-invalid-recording-recovery': { + type: 'boolean', + description: localize('argv.enableGraphiteInvalidRecordingRecovery', "Enables recovery from invalid Graphite recordings.") } } }; diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts index cf7c1be09b4..ea7f3641853 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts @@ -1180,12 +1180,12 @@ export class ExtensionManagementService extends CommontExtensionManagementServic const trustedPublishers = this.storageService.getObject>(TrustedPublishersStorageKey, StorageScope.APPLICATION, {}); if (Array.isArray(trustedPublishers)) { this.storageService.remove(TrustedPublishersStorageKey, StorageScope.APPLICATION); - return {}; + return Object.create(null); } return Object.keys(trustedPublishers).reduce>((result, publisher) => { result[publisher.toLowerCase()] = trustedPublishers[publisher]; return result; - }, {}); + }, Object.create(null)); } } diff --git a/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts b/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts new file mode 100644 index 00000000000..cdeca23bd2d --- /dev/null +++ b/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../../nls.js'; +import { ExtensionsRegistry, IExtensionPointUser } from '../../extensions/common/extensionsRegistry.js'; +import { isProposedApiEnabled } from '../../extensions/common/extensions.js'; +import * as resources from '../../../../base/common/resources.js'; +import { IFileService, FileChangeType } from '../../../../platform/files/common/files.js'; +import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/environmentService.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { createLinkElement } from '../../../../base/browser/dom.js'; +import { IWorkbenchThemeService } from '../common/workbenchThemeService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; + +interface ICSSExtensionPoint { + path: string; +} + +const CSS_CACHE_STORAGE_KEY = 'workbench.contrib.css.cache'; + +interface ICSSCacheEntry { + extensionId: string; + cssLocations: string[]; +} + +const cssExtensionPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'css', + jsonSchema: { + description: nls.localize('contributes.css', "Contributes CSS files to be loaded in the workbench."), + type: 'array', + items: { + type: 'object', + properties: { + path: { + description: nls.localize('contributes.css.path', "Path to the CSS file. The path is relative to the extension folder."), + type: 'string' + } + }, + required: ['path'] + }, + defaultSnippets: [{ body: [{ path: '${1:styles.css}' }] }] + } +}); + +class CSSFileWatcher implements IDisposable { + + private readonly watchedLocations = new Map(); + + constructor( + private readonly fileService: IFileService, + private readonly environmentService: IBrowserWorkbenchEnvironmentService, + private readonly onUpdate: (uri: URI) => void + ) { } + + watch(uri: URI): void { + const key = uri.toString(); + if (this.watchedLocations.has(key)) { + return; + } + + if (!this.environmentService.isExtensionDevelopment) { + return; + } + + const disposables = new DisposableStore(); + disposables.add(this.fileService.watch(uri)); + disposables.add(this.fileService.onDidFilesChange(e => { + if (e.contains(uri, FileChangeType.UPDATED)) { + this.onUpdate(uri); + } + })); + this.watchedLocations.set(key, { uri, disposables }); + } + + unwatch(uri: URI): void { + const key = uri.toString(); + const entry = this.watchedLocations.get(key); + if (entry) { + entry.disposables.dispose(); + this.watchedLocations.delete(key); + } + } + + dispose(): void { + for (const entry of this.watchedLocations.values()) { + entry.disposables.dispose(); + } + this.watchedLocations.clear(); + } +} + +export class CSSExtensionPoint { + + private readonly disposables = new DisposableStore(); + private readonly stylesheetsByExtension = new Map(); + private readonly pendingExtensions = new Map>(); + private readonly watcher: CSSFileWatcher; + + constructor( + @IFileService fileService: IFileService, + @IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService, + @IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService, + @IStorageService private readonly storageService: IStorageService + ) { + this.watcher = this.disposables.add(new CSSFileWatcher(fileService, environmentService, uri => this.reloadStylesheet(uri))); + this.disposables.add(toDisposable(() => { + for (const entries of this.stylesheetsByExtension.values()) { + for (const entry of entries) { + entry.disposables.dispose(); + } + } + this.stylesheetsByExtension.clear(); + })); + + // Apply cached CSS immediately on startup if a theme from the cached extension is active + this.applyCachedCSS(); + + // Listen to theme changes to activate/deactivate CSS + this.disposables.add(this.themeService.onDidColorThemeChange(() => this.onThemeChange())); + this.disposables.add(this.themeService.onDidFileIconThemeChange(() => this.onThemeChange())); + this.disposables.add(this.themeService.onDidProductIconThemeChange(() => this.onThemeChange())); + + cssExtensionPoint.setHandler((extensions, delta) => { + // Handle removed extensions + for (const extension of delta.removed) { + const extensionId = extension.description.identifier.value; + this.pendingExtensions.delete(extensionId); + this.removeStylesheets(extensionId); + this.clearCacheForExtension(extensionId); + } + + // Handle added extensions + for (const extension of delta.added) { + if (!isProposedApiEnabled(extension.description, 'css')) { + extension.collector.error(`The '${cssExtensionPoint.name}' contribution point is proposed API.`); + continue; + } + + const extensionValue = extension.value; + const collector = extension.collector; + + if (!extensionValue || !Array.isArray(extensionValue)) { + collector.error(nls.localize('invalid.css.configuration', "'contributes.css' must be an array.")); + continue; + } + + const extensionId = extension.description.identifier.value; + + // Store the extension for later activation + this.pendingExtensions.set(extensionId, extension); + + // Check if this extension's theme is currently active + if (this.isExtensionThemeActive(extensionId)) { + this.activateExtensionCSS(extension); + } + } + }); + } + + private isExtensionThemeActive(extensionId: string): boolean { + const colorTheme = this.themeService.getColorTheme(); + const fileIconTheme = this.themeService.getFileIconTheme(); + const productIconTheme = this.themeService.getProductIconTheme(); + + return !!(colorTheme.extensionData && ExtensionIdentifier.equals(colorTheme.extensionData.extensionId, extensionId)) || + !!(fileIconTheme.extensionData && ExtensionIdentifier.equals(fileIconTheme.extensionData.extensionId, extensionId)) || + !!(productIconTheme.extensionData && ExtensionIdentifier.equals(productIconTheme.extensionData.extensionId, extensionId)); + } + + private onThemeChange(): void { + // Check all pending extensions and activate/deactivate as needed + for (const [extensionId, extension] of this.pendingExtensions) { + const isActive = this.stylesheetsByExtension.has(extensionId); + const shouldBeActive = this.isExtensionThemeActive(extensionId); + + if (shouldBeActive && !isActive) { + this.activateExtensionCSS(extension); + } else if (!shouldBeActive && isActive) { + this.removeStylesheets(extensionId); + this.clearCacheForExtension(extensionId); + } + } + } + + private activateExtensionCSS(extension: IExtensionPointUser): void { + const extensionId = extension.description.identifier.value; + + // Already activated (e.g., from cache on startup) + if (this.stylesheetsByExtension.has(extensionId)) { + return; + } + + const extensionLocation = extension.description.extensionLocation; + const extensionValue = extension.value; + const collector = extension.collector; + + const entries: { readonly uri: URI; readonly element: HTMLLinkElement; readonly disposables: DisposableStore }[] = []; + const cssLocations: string[] = []; + + for (const cssContribution of extensionValue) { + if (!cssContribution.path || typeof cssContribution.path !== 'string') { + collector.error(nls.localize('invalid.css.path', "'contributes.css.path' must be a string.")); + continue; + } + + const cssLocation = resources.joinPath(extensionLocation, cssContribution.path); + + // Validate that the CSS file is within the extension folder + if (!resources.isEqualOrParent(cssLocation, extensionLocation)) { + collector.warn(nls.localize('invalid.css.path.location', "Expected 'contributes.css.path' ({0}) to be included inside extension's folder ({1}).", cssLocation.path, extensionLocation.path)); + continue; + } + + const entryDisposables = new DisposableStore(); + const element = this.createCSSLinkElement(cssLocation, extensionId, entryDisposables); + entries.push({ uri: cssLocation, element, disposables: entryDisposables }); + cssLocations.push(cssLocation.toString()); + + // Watch for changes + this.watcher.watch(cssLocation); + } + + if (entries.length > 0) { + this.stylesheetsByExtension.set(extensionId, entries); + + // Cache the CSS locations for faster startup next time + this.cacheExtensionCSS(extensionId, cssLocations); + } + } + + private removeStylesheets(extensionId: string): void { + const entries = this.stylesheetsByExtension.get(extensionId); + if (entries) { + for (const entry of entries) { + this.watcher.unwatch(entry.uri); + entry.disposables.dispose(); + } + this.stylesheetsByExtension.delete(extensionId); + } + } + + private applyCachedCSS(): void { + const cached = this.getCachedCSS(); + if (!cached) { + return; + } + + // Check if a theme from the cached extension is active + if (!this.isExtensionThemeActive(cached.extensionId)) { + // Theme changed, invalidate the cache + this.clearCacheForExtension(cached.extensionId); + return; + } + + // Apply cached CSS immediately + const entries: { readonly uri: URI; readonly element: HTMLLinkElement; readonly disposables: DisposableStore }[] = []; + + for (const cssLocationString of cached.cssLocations) { + const cssLocation = URI.parse(cssLocationString); + const entryDisposables = new DisposableStore(); + const element = this.createCSSLinkElement(cssLocation, cached.extensionId, entryDisposables); + entries.push({ uri: cssLocation, element, disposables: entryDisposables }); + + // Watch for changes + this.watcher.watch(cssLocation); + } + + if (entries.length > 0) { + this.stylesheetsByExtension.set(cached.extensionId, entries); + } + } + + private getCachedCSS(): ICSSCacheEntry | undefined { + const raw = this.storageService.get(CSS_CACHE_STORAGE_KEY, StorageScope.PROFILE); + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw); + } catch { + return undefined; + } + } + + private cacheExtensionCSS(extensionId: string, cssLocations: string[]): void { + const entry: ICSSCacheEntry = { extensionId, cssLocations }; + this.storageService.store(CSS_CACHE_STORAGE_KEY, JSON.stringify(entry), StorageScope.PROFILE, StorageTarget.MACHINE); + } + + private clearCacheForExtension(extensionId: string): void { + const cached = this.getCachedCSS(); + if (cached && ExtensionIdentifier.equals(cached.extensionId, extensionId)) { + this.storageService.remove(CSS_CACHE_STORAGE_KEY, StorageScope.PROFILE); + } + } + + private createCSSLinkElement(uri: URI, extensionId: string, disposables: DisposableStore): HTMLLinkElement { + const element = createLinkElement(); + element.rel = 'stylesheet'; + element.type = 'text/css'; + element.className = `extension-contributed-css ${extensionId}`; + element.href = FileAccess.uriToBrowserUri(uri).toString(true); + disposables.add(toDisposable(() => element.remove())); + return element; + } + + private reloadStylesheet(uri: URI): void { + const uriString = uri.toString(); + for (const entries of this.stylesheetsByExtension.values()) { + for (const entry of entries) { + if (entry.uri.toString() === uriString) { + // Cache-bust by adding a timestamp query parameter + const browserUri = FileAccess.uriToBrowserUri(uri); + entry.element.href = browserUri.with({ query: `v=${Date.now()}` }).toString(true); + } + } + } + } + + dispose(): void { + this.disposables.dispose(); + } +} diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 08cf87eaa85..f31fad2daf2 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -107,7 +107,7 @@ declare module 'vscode' { isConfirmed?: boolean; isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData; - fromSubAgent?: boolean; + subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; constructor(toolName: string, toolCallId: string, isError?: boolean); @@ -359,7 +359,7 @@ declare module 'vscode' { * @param toolName The name of the tool being invoked. * @param streamData Optional initial streaming data with partial arguments. */ - beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData): void; + beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData & { subagentInvocationId?: string }): void; /** * Update the streaming data for a tool invocation that was started with `beginToolInvocation`. diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 6b6c670a527..39861c8e498 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -93,7 +93,11 @@ declare module 'vscode' { */ readonly editedFileEvents?: ChatRequestEditedFileEvent[]; - readonly isSubagent?: boolean; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + * Pass this to tool invocations when calling tools from within a subagent context. + */ + readonly subAgentInvocationId?: string; } export enum ChatRequestEditedFileEventKind { @@ -234,9 +238,9 @@ declare module 'vscode' { chatInteractionId?: string; terminalCommand?: string; /** - * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ - fromSubAgent?: boolean; + subAgentInvocationId?: string; } export interface LanguageModelToolInvocationPrepareOptions { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index ac6ade0f413..016b45c2916 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -26,6 +26,25 @@ declare module 'vscode' { InProgress = 2 } + export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + + /** + * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + */ + export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; + } + /** * Provides a list of information about chat sessions. */ @@ -52,6 +71,86 @@ declare module 'vscode' { // #endregion } + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemController { + readonly id: string; + + /** + * Unregisters the controller, disposing of its associated chat session items. + */ + dispose(): void; + + /** + * Managed collection of chat session items + */ + readonly items: ChatSessionItemCollection; + + /** + * Creates a new managed chat session item that be added to the collection. + */ + createChatSessionItem(resource: Uri, label: string): ChatSessionItem; + + /** + * Handler called to refresh the collection of chat session items. + * + * This is also called on first load to get the initial set of items. + */ + refreshHandler: () => Thenable; + + /** + * Fired when an item is archived by the editor + * + * TODO: expose archive state on the item too? + */ + readonly onDidArchiveChatSessionItem: Event; + } + + /** + * A collection of chat session items. It provides operations for managing and iterating over the items. + */ + export interface ChatSessionItemCollection extends Iterable { + /** + * Gets the number of items in the collection. + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store. + */ + replace(items: readonly ChatSessionItem[]): void; + + /** + * Iterate over each entry in this collection. + * + * @param callback Function to execute for each entry. + * @param thisArg The `this` context used when invoking the handler function. + */ + forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; + + /** + * Adds the chat session item to the collection. If an item with the same resource URI already + * exists, it'll be replaced. + * @param item Item to add. + */ + add(item: ChatSessionItem): void; + + /** + * Removes a single chat session item from the collection. + * @param resource Item resource to delete. + */ + delete(resource: Uri): void; + + /** + * Efficiently gets a chat session item by resource, if it exists, in the collection. + * @param resource Item resource to get. + * @returns The found item or undefined if it does not exist. + */ + get(resource: Uri): ChatSessionItem | undefined; + } + export interface ChatSessionItem { /** * The resource associated with the chat session. @@ -91,15 +190,42 @@ declare module 'vscode' { tooltip?: string | MarkdownString; /** - * The times at which session started and ended + * Whether the chat session has been archived. + */ + archived?: boolean; + + /** + * Timing information for the chat session */ timing?: { /** - * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. */ - startTime: number; + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted?: number; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded?: number; + + /** + * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `created` and `lastRequestStarted` instead. + */ + startTime?: number; + /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. */ endTime?: number; }; @@ -268,18 +394,6 @@ declare module 'vscode' { } export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. * diff --git a/src/vscode-dts/vscode.proposed.css.d.ts b/src/vscode-dts/vscode.proposed.css.d.ts new file mode 100644 index 00000000000..3bf4c59dbae --- /dev/null +++ b/src/vscode-dts/vscode.proposed.css.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for `contributes.css` diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 7438ab0d27a..00b5019d0b4 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -839,6 +839,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 82023c01c1a..2fbd0a5eaf5 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -30,7 +30,7 @@ interface ITargetMetadata { */ export class TestContext { private static readonly authenticodeInclude = /^.+\.(exe|dll|sys|cab|cat|msi|jar|ocx|ps1|psm1|psd1|ps1xml|pssc1)$/i; - private static readonly codesignExclude = /node_modules\/(@parcel\/watcher\/build\/Release\/watcher\.node|@vscode\/deviceid\/build\/Release\/windows\.node|@vscode\/ripgrep\/bin\/rg|@vscode\/spdlog\/build\/Release\/spdlog.node|kerberos\/build\/Release\/kerberos.node|native-watchdog\/build\/Release\/watchdog\.node|node-pty\/build\/Release\/(pty\.node|spawn-helper)|vsda\/build\/Release\/vsda\.node)$/; + private static readonly codesignExclude = /node_modules\/(@parcel\/watcher\/build\/Release\/watcher\.node|@vscode\/deviceid\/build\/Release\/windows\.node|@vscode\/ripgrep\/bin\/rg|@vscode\/spdlog\/build\/Release\/spdlog.node|kerberos\/build\/Release\/kerberos.node|@vscode\/native-watchdog\/build\/Release\/watchdog\.node|node-pty\/build\/Release\/(pty\.node|spawn-helper)|vsda\/build\/Release\/vsda\.node)$/; private readonly tempDirs = new Set(); private _currentTest?: Mocha.Test & { consoleOutputs?: string[] };