diff --git a/.vscode/settings.json b/.vscode/settings.json index da775f21244..3e903f64675 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -94,6 +94,7 @@ "**/*.snap": true, }, // --- TypeScript --- + "typescript.experimental.useTsgo": true, "typescript.tsdk": "node_modules/typescript/lib", "typescript.preferences.importModuleSpecifier": "relative", "typescript.preferences.quoteStyle": "single", diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index f48738be53a..cae158ea590 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -166,7 +166,7 @@ const tasks = compilations.map(function (tsconfigFile) { const compileTask = task.define(`compile-extension:${name}`, task.series(cleanTask, async () => { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const copyNonTs = util.streamToPromise(nonts.pipe(gulp.dest(out))); - const tsgo = spawnTsgo(absolutePath, { reporterId: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); + const tsgo = spawnTsgo(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); await Promise.all([copyNonTs, tsgo]); })); @@ -175,7 +175,7 @@ const tasks = compilations.map(function (tsconfigFile) { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const watchInput = watcher(src, { ...srcOpts, ...{ readDelay: 200 } }); const watchNonTs = watchInput.pipe(filter(['**', '!**/*.ts'], { dot: true })).pipe(gulp.dest(out)); - const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, { reporterId: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); + const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); const watchStream = es.merge(nonts.pipe(gulp.dest(out)), watchNonTs, tsgoStream); return watchStream; @@ -276,9 +276,9 @@ gulp.task(watchWebExtensionsTask); async function buildWebExtensions(isWatch: boolean): Promise { const extensionsPath = path.join(root, 'extensions'); - // Find all esbuild-browser.ts files + // Find all esbuild.browser.mts files const esbuildConfigLocations = await nodeUtil.promisify(glob)( - path.join(extensionsPath, '**', 'esbuild-browser.ts'), + path.join(extensionsPath, '**', 'esbuild.browser.mts'), { ignore: ['**/node_modules'] } ); @@ -293,7 +293,11 @@ async function buildWebExtensions(isWatch: boolean): Promise { // Esbuild for extensions if (esbuildConfigLocations.length > 0) { - promises.push(ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script })))); + promises.push( + ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script }))), + // Also run type check on extensions + ...esbuildConfigLocations.map(script => ext.typeCheckExtension(path.dirname(script), true)) + ); } // Run webpack for remaining extensions diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 7f5138166fe..ea4f88df500 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -15,7 +15,7 @@ import electron from '@vscode/gulp-electron'; import jsonEditor from 'gulp-json-editor'; import * as util from './lib/util.ts'; import { getVersion } from './lib/getVersion.ts'; -import { readISODate } from './lib/date.ts'; +import { readISODate, writeISODate } from './lib/date.ts'; import * as task from './lib/task.ts'; import buildfile from './buildfile.ts'; import * as optimize from './lib/optimize.ts'; @@ -253,6 +253,7 @@ const coreCIEsbuild = task.define('core-ci-esbuild', task.series( cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, + writeISODate('out-build'), // Type-check with tsgo (no emit) task.define('tsgo-typecheck', () => runTsGoTypeCheck()), // Transpile individual files to out-build first (for unit tests) @@ -638,6 +639,7 @@ BUILD_TARGETS.forEach(buildTarget => { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, + writeISODate('out-build'), esbuildBundleTask, vscodeTaskCI )); diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index cea54bff8b9..fac7946fc98 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -25,6 +25,7 @@ import { getProductionDependencies } from './dependencies.ts'; import { type IExtensionDefinition, getExtensionStream } from './builtInExtensions.ts'; import { getVersion } from './getVersion.ts'; import { fetchUrls, fetchGithub } from './fetch.ts'; +import { createTsgoStream, spawnTsgo } from './tsgo.ts'; import vzip from 'gulp-vinyl-zip'; import { createRequire } from 'module'; @@ -67,23 +68,27 @@ function updateExtensionPackageJSON(input: Stream, update: (data: any) => any): function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolean): Stream { const esbuildConfigFileName = forWeb - ? 'esbuild-browser.ts' - : 'esbuild.ts'; + ? 'esbuild.browser.mts' + : 'esbuild.mts'; const webpackConfigFileName = forWeb ? `extension-browser.webpack.config.js` : `extension.webpack.config.js`; const hasEsbuild = fs.existsSync(path.join(extensionPath, esbuildConfigFileName)); - const isWebPacked = fs.existsSync(path.join(extensionPath, webpackConfigFileName)); + const hasWebpack = fs.existsSync(path.join(extensionPath, webpackConfigFileName)); let input: Stream; let isBundled = false; if (hasEsbuild) { - input = fromLocalEsbuild(extensionPath, esbuildConfigFileName); + // Unlike webpack, esbuild only does bundling so we still want to run a separate type check step + input = es.merge( + fromLocalEsbuild(extensionPath, esbuildConfigFileName), + typeCheckExtensionStream(extensionPath, forWeb), + ); isBundled = true; - } else if (isWebPacked) { + } else if (hasWebpack) { input = fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle); isBundled = true; } else { @@ -105,6 +110,17 @@ function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolea return input; } +export function typeCheckExtension(extensionPath: string, forWeb: boolean): Promise { + const tsconfigFileName = forWeb ? 'tsconfig.browser.json' : 'tsconfig.json'; + const tsconfigPath = path.join(extensionPath, tsconfigFileName); + return spawnTsgo(tsconfigPath, { taskName: 'typechecking extension (tsgo)', noEmit: true }); +} + +export function typeCheckExtensionStream(extensionPath: string, forWeb: boolean): Stream { + const tsconfigFileName = forWeb ? 'tsconfig.browser.json' : 'tsconfig.json'; + const tsconfigPath = path.join(extensionPath, tsconfigFileName); + return createTsgoStream(tsconfigPath, { taskName: 'typechecking extension (tsgo)', noEmit: true }); +} function fromLocalWebpack(extensionPath: string, webpackConfigFileName: string, disableMangle: boolean): Stream { const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); @@ -267,6 +283,7 @@ function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): if (error) { return reject(error); } + const matches = (stderr || '').match(/\> (.+): error: (.+)?/g); fancyLog(`Bundled extension: ${ansiColors.yellow(path.join(path.basename(extensionPath), esbuildConfigFileName))} with ${matches ? matches.length : 0} errors.`); for (const match of matches || []) { @@ -632,17 +649,6 @@ export function translatePackageJSON(packageJSON: string, packageNLSPath: string const extensionsPath = path.join(root, 'extensions'); -// Additional projects to run esbuild on. These typically build code for webviews -const esbuildMediaScripts = [ - 'ipynb/esbuild.mjs', - 'markdown-language-features/esbuild-notebook.mjs', - 'markdown-language-features/esbuild-preview.mjs', - 'markdown-math/esbuild.mjs', - 'mermaid-chat-features/esbuild-chat-webview.mjs', - 'notebook-renderers/esbuild.mjs', - 'simple-browser/esbuild-preview.mjs', -]; - export async function webpackExtensions(taskName: string, isWatch: boolean, webpackConfigLocations: { configPath: string; outputRoot?: string }[]) { const webpack = require('webpack') as typeof import('webpack'); @@ -742,6 +748,18 @@ export async function esbuildExtensions(taskName: string, isWatch: boolean, scri await Promise.all(tasks); } + +// Additional projects to run esbuild on. These typically build code for webviews +const esbuildMediaScripts = [ + 'ipynb/esbuild.notebook.mts', + 'markdown-language-features/esbuild.notebook.mts', + 'markdown-language-features/esbuild.webview.mts', + 'markdown-math/esbuild.notebook.mts', + 'mermaid-chat-features/esbuild.webview.mts', + 'notebook-renderers/esbuild.notebook.mts', + 'simple-browser/esbuild.webview.mts', +]; + export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Promise { return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(p => ({ script: path.join(extensionsPath, p), diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 53387c4fa34..7489336e814 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -169,6 +169,20 @@ "type": "boolean", "default": true }, + { + "key": "chat.useHooks", + "name": "ChatHooks", + "category": "InteractiveSession", + "minimumVersion": "1.109", + "localization": { + "description": { + "key": "chat.useHooks.description", + "value": "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`." + } + }, + "type": "boolean", + "default": true + }, { "key": "chat.tools.terminal.enableAutoApprove", "name": "ChatToolsTerminalEnableAutoApprove", diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts index 3a245fe5cb6..421f4c1cc1b 100644 --- a/build/lib/tsgo.ts +++ b/build/lib/tsgo.ts @@ -3,37 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import ansiColors from 'ansi-colors'; import * as cp from 'child_process'; import es from 'event-stream'; +import fancyLog from 'fancy-log'; import * as path from 'path'; -import { createReporter } from './reporter.ts'; const root = path.dirname(path.dirname(import.meta.dirname)); const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; -export function spawnTsgo(projectPath: string, config: { reporterId: string }, onComplete?: () => Promise | void): Promise { - const reporter = createReporter(config.reporterId); - let report: NodeJS.ReadWriteStream | undefined; - - const beginReport = (emitError: boolean) => { - if (report) { - report.end(); +export function spawnTsgo(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): Promise { + function reporter(stdError: string) { + const matches = (stdError || '').match(/^error \w+: (.+)?/g); + fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${matches ? matches.length : 0} errors.`); + for (const match of matches || []) { + fancyLog.error(match); } - report = reporter.end(emitError); - }; + } - const endReport = () => { - if (!report) { - return; - } - report.end(); - report = undefined; - }; - - beginReport(false); - - const args = ['tsgo', '--project', projectPath, '--pretty', 'false', '--sourceMap', '--inlineSources']; + const args = ['tsgo', '--project', projectPath, '--pretty', 'false']; + if (config.noEmit) { + args.push('--noEmit'); + } else { + args.push('--sourceMap', '--inlineSources'); + } const child = cp.spawn(npx, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], @@ -47,23 +41,13 @@ export function spawnTsgo(projectPath: string, config: { reporterId: string }, o return; } if (/Starting compilation|File change detected/i.test(trimmed)) { - beginReport(false); return; } if (/Compilation complete/i.test(trimmed)) { - endReport(); return; } - const match = /(.*\(\d+,\d+\): )(.*: )(.*)/.exec(trimmed); - - if (match) { - const fullpath = path.isAbsolute(match[1]) ? match[1] : path.join(root, match[1]); - const message = match[3]; - reporter(fullpath + message); - } else { - reporter(trimmed); - } + reporter(trimmed); }; const handleData = (data: Buffer) => { @@ -84,7 +68,7 @@ export function spawnTsgo(projectPath: string, config: { reporterId: string }, o handleLine(buffer); buffer = ''; } - endReport(); + if (code === 0) { Promise.resolve(onComplete?.()).then(() => resolve(), reject); } else { @@ -93,15 +77,13 @@ export function spawnTsgo(projectPath: string, config: { reporterId: string }, o }); child.on('error', err => { - endReport(); reject(err); }); }); } -export function createTsgoStream(projectPath: string, config: { reporterId: string }, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { +export function createTsgoStream(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { const stream = es.through(); - spawnTsgo(projectPath, config, onComplete).then(() => { stream.emit('end'); }).catch(() => { diff --git a/build/lib/watch/watch-win32.ts b/build/lib/watch/watch-win32.ts index 12b8ffc0ac3..5b5a1197762 100644 --- a/build/lib/watch/watch-win32.ts +++ b/build/lib/watch/watch-win32.ts @@ -10,8 +10,9 @@ import File from 'vinyl'; import es from 'event-stream'; import filter from 'gulp-filter'; import { Stream } from 'stream'; +import { fileURLToPath } from 'url'; -const watcherPath = path.join(import.meta.dirname, 'watcher.exe'); +const watcherPath = path.join(typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)), 'watcher.exe'); function toChangeType(type: '0' | '1' | '2'): 'change' | 'add' | 'unlink' { switch (type) { diff --git a/build/win32/code.iss b/build/win32/code.iss index 0d47e15103f..05c8465a17b 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -86,7 +86,7 @@ Type: files; Name: "{app}\updating_version" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 0,6.1 Name: "addcontextmenufiles"; Description: "{cm:AddContextMenuFiles,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked -Name: "addcontextmenufolders"; Description: "{cm:AddContextMenuFolders,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked; Check: not IsWindows11OrLater +Name: "addcontextmenufolders"; Description: "{cm:AddContextMenuFolders,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked; Check: not ShouldUseWindows11ContextMenu Name: "associatewithfiles"; Description: "{cm:AssociateWithFiles,{#NameShort}}"; GroupDescription: "{cm:Other}" Name: "addtopath"; Description: "{cm:AddToPath}"; GroupDescription: "{cm:Other}" Name: "runcode"; Description: "{cm:RunAfter,{#NameShort}}"; GroupDescription: "{cm:Other}"; Check: WizardSilent @@ -105,8 +105,8 @@ Source: "bin\{#ApplicationName}.cmd"; DestDir: "{code:GetDestDir}\bin"; DestName Source: "bin\{#ApplicationName}"; DestDir: "{code:GetDestDir}\bin"; DestName: "{code:GetBinDirApplicationFilename}"; Flags: ignoreversion Source: "{#ProductJsonPath}"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\resources\app"; Flags: ignoreversion #ifdef AppxPackageName -Source: "appx\{#AppxPackage}"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\appx"; BeforeInstall: RemoveAppxPackage; Flags: ignoreversion; Check: IsWindows11OrLater -Source: "appx\{#AppxPackageDll}"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\appx"; AfterInstall: AddAppxPackage; Flags: ignoreversion; Check: IsWindows11OrLater +Source: "appx\{#AppxPackage}"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\appx"; BeforeInstall: RemoveAppxPackage; Flags: ignoreversion; Check: ShouldUseWindows11ContextMenu +Source: "appx\{#AppxPackageDll}"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\appx"; AfterInstall: AddAppxPackage; Flags: ignoreversion; Check: ShouldUseWindows11ContextMenu #endif [Icons] @@ -1272,19 +1272,19 @@ Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Applications\{#ExeBas Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Applications\{#ExeBasename}.exe\shell\open"; ValueType: string; ValueName: "Icon"; ValueData: """{app}\{#ExeBasename}.exe""" Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Applications\{#ExeBasename}.exe\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%1""" -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\{#RegValueName}ContextMenu"; ValueType: expandsz; ValueName: "Title"; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufiles; Flags: uninsdeletekey; Check: IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufiles; Flags: uninsdeletekey; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufiles; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%1"""; Tasks: addcontextmenufiles; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not IsWindows11OrLater -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not IsWindows11OrLater +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\{#RegValueName}ContextMenu"; ValueType: expandsz; ValueName: "Title"; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufiles; Flags: uninsdeletekey; Check: ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufiles; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufiles; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%1"""; Tasks: addcontextmenufiles; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu ; Environment #if "user" == InstallTarget @@ -1472,6 +1472,23 @@ begin Result := (GetWindowsVersion >= $0A0055F0); end; +function IsWindows10ContextMenuForced(): Boolean; +var + SubKey: String; +begin + // Check if the user has forced Windows 10 style context menus on Windows 11 + SubKey := 'Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32'; + Result := RegKeyExists(HKEY_CURRENT_USER, SubKey) or RegKeyExists(HKEY_LOCAL_MACHINE, SubKey); +end; + +function ShouldUseWindows11ContextMenu(): Boolean; +begin + // Use Windows 11 context menu only if: + // 1. Running on Windows 11 or later + // 2. User has NOT forced Windows 10 style context menus + Result := IsWindows11OrLater() and not IsWindows10ContextMenuForced(); +end; + function GetAppMutex(Value: string): string; begin if IsBackgroundUpdate() then @@ -1629,7 +1646,7 @@ begin begin #ifdef AppxPackageName // Remove the old context menu registry keys - if IsWindows11OrLater() then begin + if ShouldUseWindows11ContextMenu() then begin RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}'); RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\background\shell\{#RegValueName}'); diff --git a/eslint.config.js b/eslint.config.js index fa55c74032c..f60ff793db5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2067,8 +2067,9 @@ export default tseslint.config( 'extensions/markdown-language-features/src/**/*.ts', 'extensions/markdown-language-features/notebook/**/*.ts', 'extensions/markdown-language-features/preview-src/**/*.ts', - 'extensions/mermaid-chat-features/**/*.ts', - 'extensions/media-preview/**/*.ts', + 'extensions/mermaid-chat-features/chat-webview-src/**/*.ts', + 'extensions/mermaid-chat-features/src/**/*.ts', + 'extensions/media-preview/src/**/*.ts', 'extensions/simple-browser/**/*.ts', 'extensions/typescript-language-features/**/*.ts', ], diff --git a/extensions/esbuild-extension-common.ts b/extensions/esbuild-extension-common.mts similarity index 100% rename from extensions/esbuild-extension-common.ts rename to extensions/esbuild-extension-common.mts diff --git a/extensions/esbuild-webview-common.mjs b/extensions/esbuild-webview-common.mts similarity index 62% rename from extensions/esbuild-webview-common.mjs rename to extensions/esbuild-webview-common.mts index 76d03abad7d..a170e5e344f 100644 --- a/extensions/esbuild-webview-common.mjs +++ b/extensions/esbuild-webview-common.mts @@ -2,27 +2,22 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check + /** - * @fileoverview Common build script for extension scripts used in in webviews. + * Common build script for extension scripts used in in webviews. */ import path from 'node:path'; import esbuild from 'esbuild'; -/** - * @typedef {Partial & { - * entryPoints: string[] | Record | { in: string, out: string }[]; - * outdir: string; - * }} BuildOptions - */ +export type BuildOptions = Partial & { + entryPoints: string[] | Record | { in: string; out: string }[]; + outdir: string; +}; /** * Build the source code once using esbuild. - * - * @param {BuildOptions} options - * @param {(outDir: string) => unknown} [didBuild] */ -async function build(options, didBuild) { +async function build(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { await esbuild.build({ bundle: true, minify: true, @@ -38,11 +33,8 @@ async function build(options, didBuild) { /** * Build the source code once using esbuild, logging errors instead of throwing. - * - * @param {BuildOptions} options - * @param {(outDir: string) => unknown} [didBuild] */ -async function tryBuild(options, didBuild) { +async function tryBuild(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { try { await build(options, didBuild); } catch (err) { @@ -50,17 +42,16 @@ async function tryBuild(options, didBuild) { } } -/** - * @param {{ - * srcDir: string; - * outdir: string; - * entryPoints: string[] | Record | { in: string, out: string }[]; - * additionalOptions?: Partial - * }} config - * @param {string[]} args - * @param {(outDir: string) => unknown} [didBuild] - */ -export async function run(config, args, didBuild) { +export async function run( + config: { + srcDir: string; + outdir: string; + entryPoints: BuildOptions['entryPoints']; + additionalOptions?: Partial; + }, + args: string[], + didBuild?: (outDir: string) => unknown +): Promise { let outdir = config.outdir; const outputRootIndex = args.indexOf('--outputRoot'); if (outputRootIndex >= 0) { @@ -69,8 +60,7 @@ export async function run(config, args, didBuild) { outdir = path.join(outputRoot, outputDirName); } - /** @type {BuildOptions} */ - const resolvedOptions = { + const resolvedOptions: BuildOptions = { entryPoints: config.entryPoints, outdir, logOverride: { diff --git a/extensions/ipynb/esbuild.mjs b/extensions/ipynb/esbuild.notebook.mts similarity index 90% rename from extensions/ipynb/esbuild.mjs rename to extensions/ipynb/esbuild.notebook.mts index 3003959c1eb..4d45f388574 100644 --- a/extensions/ipynb/esbuild.mjs +++ b/extensions/ipynb/esbuild.notebook.mts @@ -2,9 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check import path from 'node:path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'notebook-src'); const outDir = path.join(import.meta.dirname, 'notebook-out'); diff --git a/extensions/ipynb/package.json b/extensions/ipynb/package.json index 89a24e5cc15..7396e270a47 100644 --- a/extensions/ipynb/package.json +++ b/extensions/ipynb/package.json @@ -158,7 +158,7 @@ "scripts": { "compile": "npx gulp compile-extension:ipynb && npm run build-notebook", "watch": "npx gulp watch-extension:ipynb", - "build-notebook": "node ./esbuild.mjs" + "build-notebook": "node ./esbuild.notebook.mts" }, "dependencies": { "@enonic/fnv-plus": "^1.3.0", diff --git a/extensions/markdown-language-features/.vscodeignore b/extensions/markdown-language-features/.vscodeignore index a5b7a3ec72c..315b1d78770 100644 --- a/extensions/markdown-language-features/.vscodeignore +++ b/extensions/markdown-language-features/.vscodeignore @@ -2,16 +2,12 @@ test/** test-workspace/** src/** notebook/** -tsconfig.json -tsconfig.*.json +tsconfig*.json +esbuild* out/test/** out/** -extension.webpack.config.js -extension-browser.webpack.config.js cgmanifest.json package-lock.json preview-src/** -webpack.config.js -esbuild-* .gitignore **/*.d.ts diff --git a/extensions/markdown-language-features/esbuild-browser.ts b/extensions/markdown-language-features/esbuild.browser.mts similarity index 96% rename from extensions/markdown-language-features/esbuild-browser.ts rename to extensions/markdown-language-features/esbuild.browser.mts index 2c46e390c06..ddf0c5a99dc 100644 --- a/extensions/markdown-language-features/esbuild-browser.ts +++ b/extensions/markdown-language-features/esbuild.browser.mts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); diff --git a/extensions/markdown-language-features/esbuild.ts b/extensions/markdown-language-features/esbuild.mts similarity index 95% rename from extensions/markdown-language-features/esbuild.ts rename to extensions/markdown-language-features/esbuild.mts index 67835c9a1d7..a1cf6eb5fa8 100644 --- a/extensions/markdown-language-features/esbuild.ts +++ b/extensions/markdown-language-features/esbuild.mts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); diff --git a/extensions/markdown-language-features/esbuild-notebook.mjs b/extensions/markdown-language-features/esbuild.notebook.mts similarity index 90% rename from extensions/markdown-language-features/esbuild-notebook.mjs rename to extensions/markdown-language-features/esbuild.notebook.mts index 933e77d21a5..d9d511c5e82 100644 --- a/extensions/markdown-language-features/esbuild-notebook.mjs +++ b/extensions/markdown-language-features/esbuild.notebook.mts @@ -2,9 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check import path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'notebook'); const outDir = path.join(import.meta.dirname, 'notebook-out'); diff --git a/extensions/markdown-language-features/esbuild-preview.mjs b/extensions/markdown-language-features/esbuild.webview.mts similarity index 90% rename from extensions/markdown-language-features/esbuild-preview.mjs rename to extensions/markdown-language-features/esbuild.webview.mts index 1d3fc48b9bc..c4141cf50a5 100644 --- a/extensions/markdown-language-features/esbuild-preview.mjs +++ b/extensions/markdown-language-features/esbuild.webview.mts @@ -2,9 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check import path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'preview-src'); const outDir = path.join(import.meta.dirname, 'media'); diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index edffec39d74..c9d0de68d86 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -757,14 +757,20 @@ ] }, "scripts": { - "compile": "gulp compile-extension:markdown-language-features-languageService && gulp compile-extension:markdown-language-features && npm run build-preview && npm run build-notebook", - "watch": "npm run build-preview && gulp watch-extension:markdown-language-features watch-extension:markdown-language-features-languageService", - "vscode:prepublish": "npm run build-ext && npm run build-preview", - "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:markdown-language-features ./tsconfig.json", - "build-notebook": "node ./esbuild-notebook.mjs", - "build-preview": "node ./esbuild-preview.mjs", - "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", - "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch" + "compile": "npm-run-all2 -lp build-ext build-webview build-notebook", + "watch": "npm-run-all2 -lp watch-ext watch-webview watch-notebook", + "build-ext": "gulp compile-extension:markdown-language-features", + "watch-ext": "gulp watch-extension:markdown-language-features", + "build-notebook": "node ./esbuild.notebook.mts", + "watch-notebook": "node ./esbuild.notebook.mts --watch", + "build-webview": "node ./esbuild.webview.mts", + "watch-webview": "node ./esbuild.webview.mts --watch", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch" }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", diff --git a/extensions/markdown-language-features/tsconfig.browser.json b/extensions/markdown-language-features/tsconfig.browser.json index dbacbb22fdf..790349e7fec 100644 --- a/extensions/markdown-language-features/tsconfig.browser.json +++ b/extensions/markdown-language-features/tsconfig.browser.json @@ -3,5 +3,8 @@ "compilerOptions": {}, "exclude": [ "./src/test/**" + ], + "files": [ + "./src/extension.browser.ts" ] } diff --git a/extensions/markdown-math/.vscodeignore b/extensions/markdown-math/.vscodeignore index 5df4a1cb8ab..90098845502 100644 --- a/extensions/markdown-math/.vscodeignore +++ b/extensions/markdown-math/.vscodeignore @@ -1,10 +1,7 @@ src/** notebook/** -extension-browser.webpack.config.js -extension.webpack.config.js -esbuild.* +tsconfig*.json +esbuild* cgmanifest.json package-lock.json -webpack.config.js -tsconfig.json .gitignore diff --git a/extensions/markdown-math/esbuild.browser.mts b/extensions/markdown-math/esbuild.browser.mts new file mode 100644 index 00000000000..e3fa7792d05 --- /dev/null +++ b/extensions/markdown-math/esbuild.browser.mts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + }, +}, process.argv); diff --git a/extensions/markdown-math/esbuild.mts b/extensions/markdown-math/esbuild.mts new file mode 100644 index 00000000000..5fafb57ab75 --- /dev/null +++ b/extensions/markdown-math/esbuild.mts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/markdown-math/esbuild.mjs b/extensions/markdown-math/esbuild.notebook.mts similarity index 89% rename from extensions/markdown-math/esbuild.mjs rename to extensions/markdown-math/esbuild.notebook.mts index 910acbb06a8..c5ac472b3bd 100644 --- a/extensions/markdown-math/esbuild.mjs +++ b/extensions/markdown-math/esbuild.notebook.mts @@ -2,18 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -//@ts-check - -import path from 'path'; import fse from 'fs-extra'; -import { run } from '../esbuild-webview-common.mjs'; - -const args = process.argv.slice(2); +import path from 'path'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'notebook'); const outDir = path.join(import.meta.dirname, 'notebook-out'); -function postBuild(outDir) { +function postBuild(outDir: string) { fse.copySync( path.join(import.meta.dirname, 'node_modules', 'katex', 'dist', 'katex.min.css'), path.join(outDir, 'katex.min.css')); diff --git a/extensions/markdown-math/extension-browser.webpack.config.js b/extensions/markdown-math/extension-browser.webpack.config.js deleted file mode 100644 index b758f2d8155..00000000000 --- a/extensions/markdown-math/extension-browser.webpack.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - } -}); diff --git a/extensions/markdown-math/extension.webpack.config.js b/extensions/markdown-math/extension.webpack.config.js deleted file mode 100644 index 4928186ae55..00000000000 --- a/extensions/markdown-math/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - } -}); diff --git a/extensions/markdown-math/package.json b/extensions/markdown-math/package.json index 5af72e0b513..19f20fcd04a 100644 --- a/extensions/markdown-math/package.json +++ b/extensions/markdown-math/package.json @@ -108,7 +108,7 @@ "scripts": { "compile": "npm run build-notebook", "watch": "npm run build-notebook", - "build-notebook": "node ./esbuild.mjs" + "build-notebook": "node ./esbuild.notebook.mts" }, "devDependencies": { "@types/markdown-it": "^0.0.0", diff --git a/extensions/markdown-math/tsconfig.browser.json b/extensions/markdown-math/tsconfig.browser.json new file mode 100644 index 00000000000..715a07ebfb8 --- /dev/null +++ b/extensions/markdown-math/tsconfig.browser.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [], + "typeRoots": [ + "./node_modules/@types" + ] + } +} diff --git a/extensions/media-preview/.vscodeignore b/extensions/media-preview/.vscodeignore index 532c87f6f2e..8621eb9e9f4 100644 --- a/extensions/media-preview/.vscodeignore +++ b/extensions/media-preview/.vscodeignore @@ -1,10 +1,9 @@ test/** src/** -tsconfig.json +tsconfig*.json +esbuild* out/test/** out/** -extension.webpack.config.js -extension-browser.webpack.config.js cgmanifest.json package-lock.json preview-src/** diff --git a/extensions/media-preview/esbuild.browser.mts b/extensions/media-preview/esbuild.browser.mts new file mode 100644 index 00000000000..e3fa7792d05 --- /dev/null +++ b/extensions/media-preview/esbuild.browser.mts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + }, +}, process.argv); diff --git a/extensions/media-preview/esbuild.mts b/extensions/media-preview/esbuild.mts new file mode 100644 index 00000000000..5fafb57ab75 --- /dev/null +++ b/extensions/media-preview/esbuild.mts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/media-preview/extension-browser.webpack.config.js b/extensions/media-preview/extension-browser.webpack.config.js deleted file mode 100644 index 6c86474b4e5..00000000000 --- a/extensions/media-preview/extension-browser.webpack.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - }, -}); diff --git a/extensions/media-preview/extension.webpack.config.js b/extensions/media-preview/extension.webpack.config.js deleted file mode 100644 index 4928186ae55..00000000000 --- a/extensions/media-preview/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - } -}); diff --git a/extensions/media-preview/tsconfig.browser.json b/extensions/media-preview/tsconfig.browser.json new file mode 100644 index 00000000000..3694afc77ee --- /dev/null +++ b/extensions/media-preview/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig" +} diff --git a/extensions/mermaid-chat-features/esbuild.browser.mts b/extensions/mermaid-chat-features/esbuild.browser.mts new file mode 100644 index 00000000000..e3fa7792d05 --- /dev/null +++ b/extensions/mermaid-chat-features/esbuild.browser.mts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + }, +}, process.argv); diff --git a/extensions/mermaid-chat-features/esbuild.mts b/extensions/mermaid-chat-features/esbuild.mts new file mode 100644 index 00000000000..5fafb57ab75 --- /dev/null +++ b/extensions/mermaid-chat-features/esbuild.mts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/mermaid-chat-features/esbuild-chat-webview.mjs b/extensions/mermaid-chat-features/esbuild.webview.mts similarity index 92% rename from extensions/mermaid-chat-features/esbuild-chat-webview.mjs rename to extensions/mermaid-chat-features/esbuild.webview.mts index e242585b1c3..41cfa12139e 100644 --- a/extensions/mermaid-chat-features/esbuild-chat-webview.mjs +++ b/extensions/mermaid-chat-features/esbuild.webview.mts @@ -2,9 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check import path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'chat-webview-src'); const outDir = path.join(import.meta.dirname, 'chat-webview-out'); diff --git a/extensions/mermaid-chat-features/extension.webpack.config.js b/extensions/mermaid-chat-features/extension.webpack.config.js deleted file mode 100644 index 4928186ae55..00000000000 --- a/extensions/mermaid-chat-features/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - } -}); diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 16b6a03ce48..64c31782461 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -117,7 +117,7 @@ "watch": "npm run build-chat-webview && gulp watch-extension:mermaid-chat-features", "vscode:prepublish": "npm run build-ext && npm run build-chat-webview", "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:mermaid-chat-features", - "build-chat-webview": "node ./esbuild-chat-webview.mjs", + "build-chat-webview": "node ./esbuild.webview.mts", "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" }, diff --git a/extensions/mermaid-chat-features/tsconfig.browser.json b/extensions/mermaid-chat-features/tsconfig.browser.json new file mode 100644 index 00000000000..3694afc77ee --- /dev/null +++ b/extensions/mermaid-chat-features/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig" +} diff --git a/extensions/notebook-renderers/esbuild.mjs b/extensions/notebook-renderers/esbuild.notebook.mts similarity index 90% rename from extensions/notebook-renderers/esbuild.mjs rename to extensions/notebook-renderers/esbuild.notebook.mts index 890aacd19bf..ab241d8601d 100644 --- a/extensions/notebook-renderers/esbuild.mjs +++ b/extensions/notebook-renderers/esbuild.notebook.mts @@ -2,9 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check import path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'renderer-out'); diff --git a/extensions/notebook-renderers/package.json b/extensions/notebook-renderers/package.json index 77c042ee663..715cfc03e85 100644 --- a/extensions/notebook-renderers/package.json +++ b/extensions/notebook-renderers/package.json @@ -44,7 +44,7 @@ "scripts": { "compile": "npx gulp compile-extension:notebook-renderers && npm run build-notebook", "watch": "npx gulp compile-watch:notebook-renderers", - "build-notebook": "node ./esbuild.mjs" + "build-notebook": "node ./esbuild.notebook.mts" }, "devDependencies": { "@types/jsdom": "^21.1.0", diff --git a/extensions/simple-browser/esbuild-preview.mjs b/extensions/simple-browser/esbuild.webview.mts similarity index 92% rename from extensions/simple-browser/esbuild-preview.mjs rename to extensions/simple-browser/esbuild.webview.mts index 3ce58360a30..0f91843610b 100644 --- a/extensions/simple-browser/esbuild-preview.mjs +++ b/extensions/simple-browser/esbuild.webview.mts @@ -2,9 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check import path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'preview-src'); const outDir = path.join(import.meta.dirname, 'media'); diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 0d558eeebf6..d372992c897 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -67,11 +67,11 @@ ] }, "scripts": { - "compile": "gulp compile-extension:simple-browser && npm run build-preview", - "watch": "npm run build-preview && gulp watch-extension:simple-browser", - "vscode:prepublish": "npm run build-ext && npm run build-preview", + "compile": "gulp compile-extension:simple-browser && npm run build-webview", + "watch": "npm run build-webview && gulp watch-extension:simple-browser", + "vscode:prepublish": "npm run build-ext && npm run build-webview", "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:simple-browser ./tsconfig.json", - "build-preview": "node ./esbuild-preview.mjs", + "build-webview": "node ./esbuild.webview.mts", "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" }, diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 2aca9467744..44d0d3273f3 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -94,7 +94,7 @@ "menu.foreground": "#bfbfbf", "menu.selectionBackground": "#3994BC26", "menu.selectionForeground": "#bfbfbf", - "menu.separatorBackground": "#838485", + "menu.separatorBackground": "#2A2B2C", "menu.border": "#2A2B2CFF", "commandCenter.foreground": "#bfbfbf", "commandCenter.activeForeground": "#bfbfbf", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 035e15149e7..7c46dad4509 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -283,10 +283,7 @@ /* Context Menus */ .monaco-workbench .monaco-menu .monaco-action-bar.vertical { - box-shadow: var(--shadow-lg); border-radius: var(--radius-lg); - backdrop-filter: var(--backdrop-blur-md); - -webkit-backdrop-filter: var(--backdrop-blur-md); } .monaco-workbench .context-view .monaco-menu { @@ -295,6 +292,11 @@ border-radius: var(--radius-lg); } +.monaco-workbench .monaco-menu-container > .monaco-scrollable-element { + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-lg) !important; +} + .monaco-workbench .action-widget { background: color-mix(in srgb, var(--vscode-menu-background) 60%, transparent) !important; backdrop-filter: var(--backdrop-blur-md); diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 4cd0263a1a0..03d98f3efe7 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -209,7 +209,7 @@ "js/ts.referencesCodeLens.showOnAllFunctions": { "type": "boolean", "default": false, - "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDescription": "%configuration.referencesCodeLens.showOnAllFunctions%", "scope": "language-overridable", "tags": [ "JavaScript", @@ -219,14 +219,14 @@ "javascript.referencesCodeLens.showOnAllFunctions": { "type": "boolean", "default": false, - "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDescription": "%configuration.referencesCodeLens.showOnAllFunctions%", "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", "scope": "window" }, "typescript.referencesCodeLens.showOnAllFunctions": { "type": "boolean", "default": false, - "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDescription": "%configuration.referencesCodeLens.showOnAllFunctions%", "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", "scope": "window" }, @@ -236,7 +236,6 @@ "description": "%configuration.implementationsCodeLens.enabled%", "scope": "language-overridable", "tags": [ - "JavaScript", "TypeScript" ] }, @@ -250,34 +249,32 @@ "js/ts.implementationsCodeLens.showOnInterfaceMethods": { "type": "boolean", "default": false, - "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "markdownDescription": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", "scope": "language-overridable", "tags": [ - "JavaScript", "TypeScript" ] }, "typescript.implementationsCodeLens.showOnInterfaceMethods": { "type": "boolean", "default": false, - "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "markdownDescription": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage%", "scope": "window" }, "js/ts.implementationsCodeLens.showOnAllClassMethods": { "type": "boolean", "default": false, - "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "markdownDescription": "%configuration.implementationsCodeLens.showOnAllClassMethods%", "scope": "language-overridable", "tags": [ - "JavaScript", "TypeScript" ] }, "typescript.implementationsCodeLens.showOnAllClassMethods": { "type": "boolean", "default": false, - "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "markdownDescription": "%configuration.implementationsCodeLens.showOnAllClassMethods%", "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage%", "scope": "window" }, @@ -1299,7 +1296,7 @@ "title": "%configuration.inlayHints%", "order": 24, "properties": { - "typescript.inlayHints.parameterNames.enabled": { + "js/ts.inlayHints.parameterNames.enabled": { "type": "string", "enum": [ "none", @@ -1313,49 +1310,11 @@ ], "default": "none", "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", - "scope": "resource" - }, - "typescript.inlayHints.parameterTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.variableTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", - "scope": "resource" - }, - "typescript.inlayHints.propertyDeclarationTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.functionLikeReturnTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.enumMemberValues.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", - "scope": "resource" + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] }, "javascript.inlayHints.parameterNames.enabled": { "type": "string", @@ -1371,42 +1330,184 @@ ], "default": "none", "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.enabled.unifiedDeprecationMessage%", "scope": "resource" }, + "typescript.inlayHints.parameterNames.enabled": { + "type": "string", + "enum": [ + "none", + "literals", + "all" + ], + "enumDescriptions": [ + "%inlayHints.parameterNames.none%", + "%inlayHints.parameterNames.literals%", + "%inlayHints.parameterNames.all%" + ], + "default": "none", + "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { "type": "boolean", "default": true, "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName.unifiedDeprecationMessage%", "scope": "resource" }, + "typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.parameterTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.inlayHints.parameterTypes.enabled": { "type": "boolean", "default": false, "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, + "typescript.inlayHints.parameterTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterTypes.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.variableTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.inlayHints.variableTypes.enabled": { "type": "boolean", "default": false, "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, + "typescript.inlayHints.variableTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { "type": "boolean", "default": true, "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName.unifiedDeprecationMessage%", "scope": "resource" }, + "typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.propertyDeclarationTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.inlayHints.propertyDeclarationTypes.enabled": { "type": "boolean", "default": false, "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.propertyDeclarationTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, + "typescript.inlayHints.propertyDeclarationTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.propertyDeclarationTypes.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.functionLikeReturnTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.inlayHints.functionLikeReturnTypes.enabled": { "type": "boolean", "default": false, "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.functionLikeReturnTypes.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "typescript.inlayHints.functionLikeReturnTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.functionLikeReturnTypes.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.enumMemberValues.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", + "scope": "language-overridable", + "tags": [ + "TypeScript" + ] + }, + "typescript.inlayHints.enumMemberValues.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.enumMemberValues.enabled.unifiedDeprecationMessage%", "scope": "resource" } } diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index ed640ef85f0..cdbce28c5a9 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -51,13 +51,13 @@ "typescript.goToProjectConfig.title": "Go to Project Configuration (tsconfig)", "configuration.referencesCodeLens.enabled": "Enable/disable references CodeLens in JavaScript and TypeScript files. This CodeLens shows the number of references for classes and exported functions and allows you to peek or navigate to them.", "configuration.referencesCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.enabled#` instead.", - "configuration.referencesCodeLens.showOnAllFunctions": "Enable/disable the references CodeLens on all functions in JavaScript and TypeScript files.", + "configuration.referencesCodeLens.showOnAllFunctions": "Enable/disable the [references CodeLens](#js/ts.referencesCodeLens.enabled) on all functions in JavaScript and TypeScript files.", "configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.showOnAllFunctions#` instead.", - "configuration.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens in TypeScript files. This CodeLens shows the implementers of a TypeScript interface.", + "configuration.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens in TypeScript files. This CodeLens shows the implementers of TypeScript interfaces.", "configuration.implementationsCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.enabled#` instead.", - "configuration.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable implementations CodeLens on TypeScript interface methods.", + "configuration.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable [implementations CodeLens](#js/ts.implementationsCodeLens.enabled) on TypeScript interface methods.", "configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnInterfaceMethods#` instead.", - "configuration.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing implementations CodeLens above all TypeScript class methods instead of only on abstract methods.", + "configuration.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing [implementations CodeLens](#js/ts.implementationsCodeLens.enabled) above all TypeScript class methods instead of only on abstract methods.", "configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnAllClassMethods#` instead.", "typescript.openTsServerLog.title": "Open TS Server log", "typescript.restartTsServer": "Restart TS Server", @@ -103,38 +103,46 @@ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.parameterNames.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.parameterNames.enabled#` instead.", "configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName": "Suppress parameter name hints on arguments whose text is identical to the parameter name.", + "configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.parameterNames.suppressWhenArgumentMatchesName#` instead.", "configuration.inlayHints.parameterTypes.enabled": { "message": "Enable/disable inlay hints for implicit parameter types:\n```typescript\n\nel.addEventListener('click', e /* :MouseEvent */ => ...)\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.parameterTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.parameterTypes.enabled#` instead.", "configuration.inlayHints.variableTypes.enabled": { "message": "Enable/disable inlay hints for implicit variable types:\n```typescript\n\nconst foo /* :number */ = Date.now();\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.variableTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.variableTypes.enabled#` instead.", "configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName": "Suppress type hints on variables whose name is identical to the type name.", + "configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.variableTypes.suppressWhenTypeMatchesName#` instead.", "configuration.inlayHints.propertyDeclarationTypes.enabled": { "message": "Enable/disable inlay hints for implicit types on property declarations:\n```typescript\n\nclass Foo {\n\tprop /* :number */ = Date.now();\n}\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.propertyDeclarationTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.propertyDeclarationTypes.enabled#` instead.", "configuration.inlayHints.functionLikeReturnTypes.enabled": { "message": "Enable/disable inlay hints for implicit return types on function signatures:\n```typescript\n\nfunction foo() /* :number */ {\n\treturn Date.now();\n} \n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.functionLikeReturnTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.functionLikeReturnTypes.enabled#` instead.", "configuration.inlayHints.enumMemberValues.enabled": { "message": "Enable/disable inlay hints for member values in enum declarations:\n```typescript\n\nenum MyValue {\n\tA /* = 0 */;\n\tB /* = 1 */;\n}\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.enumMemberValues.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.enumMemberValues.enabled#` instead.", "taskDefinition.tsconfig.description": "The tsconfig file that defines the TS build.", "javascript.suggestionActions.enabled": "Enable/disable suggestion diagnostics for JavaScript files in the editor.", "typescript.suggestionActions.enabled": "Enable/disable suggestion diagnostics for TypeScript files in the editor.", diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index 6da5bb74cd7..f6ede823fcd 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -10,6 +10,7 @@ import { isTypeScriptDocument } from '../configuration/languageIds'; import { API } from '../tsServer/api'; import type * as Proto from '../tsServer/protocol/protocol'; import { ITypeScriptServiceClient } from '../typescriptService'; +import { readUnifiedConfig } from '../utils/configuration'; import { Disposable } from '../utils/dispose'; import { equals } from '../utils/objects'; import { ResourceMap } from '../utils/resourceMap'; @@ -206,7 +207,7 @@ export default class FileConfigurationManager extends Disposable { disableLineTextInReferences: true, interactiveInlayHints: true, includeCompletionsForModuleExports: config.get('suggest.autoImports'), - ...getInlayHintsPreferences(config), + ...getInlayHintsPreferences(document, isTypeScriptDocument(document) ? 'typescript' : 'javascript'), ...this.getOrganizeImportsPreferences(preferencesConfig), maximumHoverLength: this.getMaximumHoverLength(document), }; @@ -274,31 +275,32 @@ function withDefaultAsUndefined(value: T, def: O): Exclude return value === def ? undefined : value as Exclude; } -export class InlayHintSettingNames { - static readonly parameterNamesSuppressWhenArgumentMatchesName = 'inlayHints.parameterNames.suppressWhenArgumentMatchesName'; - static readonly parameterNamesEnabled = 'inlayHints.parameterTypes.enabled'; - static readonly variableTypesEnabled = 'inlayHints.variableTypes.enabled'; - static readonly variableTypesSuppressWhenTypeMatchesName = 'inlayHints.variableTypes.suppressWhenTypeMatchesName'; - static readonly propertyDeclarationTypesEnabled = 'inlayHints.propertyDeclarationTypes.enabled'; - static readonly functionLikeReturnTypesEnabled = 'inlayHints.functionLikeReturnTypes.enabled'; - static readonly enumMemberValuesEnabled = 'inlayHints.enumMemberValues.enabled'; -} +export const InlayHintSettingNames = Object.freeze({ + parameterNamesEnabled: 'inlayHints.parameterNames.enabled', + parameterNamesSuppressWhenArgumentMatchesName: 'inlayHints.parameterNames.suppressWhenArgumentMatchesName', + parameterTypesEnabled: 'inlayHints.parameterTypes.enabled', + variableTypesEnabled: 'inlayHints.variableTypes.enabled', + variableTypesSuppressWhenTypeMatchesName: 'inlayHints.variableTypes.suppressWhenTypeMatchesName', + propertyDeclarationTypesEnabled: 'inlayHints.propertyDeclarationTypes.enabled', + functionLikeReturnTypesEnabled: 'inlayHints.functionLikeReturnTypes.enabled', + enumMemberValuesEnabled: 'inlayHints.enumMemberValues.enabled', +}); -export function getInlayHintsPreferences(config: vscode.WorkspaceConfiguration) { +export function getInlayHintsPreferences(scope: vscode.ConfigurationScope, fallbackSection: string) { return { - includeInlayParameterNameHints: getInlayParameterNameHintsPreference(config), - includeInlayParameterNameHintsWhenArgumentMatchesName: !config.get(InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, true), - includeInlayFunctionParameterTypeHints: config.get(InlayHintSettingNames.parameterNamesEnabled, false), - includeInlayVariableTypeHints: config.get(InlayHintSettingNames.variableTypesEnabled, false), - includeInlayVariableTypeHintsWhenTypeMatchesName: !config.get(InlayHintSettingNames.variableTypesSuppressWhenTypeMatchesName, true), - includeInlayPropertyDeclarationTypeHints: config.get(InlayHintSettingNames.propertyDeclarationTypesEnabled, false), - includeInlayFunctionLikeReturnTypeHints: config.get(InlayHintSettingNames.functionLikeReturnTypesEnabled, false), - includeInlayEnumMemberValueHints: config.get(InlayHintSettingNames.enumMemberValuesEnabled, false), + includeInlayParameterNameHints: getInlayParameterNameHintsPreference(scope, fallbackSection), + includeInlayParameterNameHintsWhenArgumentMatchesName: !readUnifiedConfig(InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, true, { scope, fallbackSection }), + includeInlayFunctionParameterTypeHints: readUnifiedConfig(InlayHintSettingNames.parameterTypesEnabled, false, { scope, fallbackSection }), + includeInlayVariableTypeHints: readUnifiedConfig(InlayHintSettingNames.variableTypesEnabled, false, { scope, fallbackSection }), + includeInlayVariableTypeHintsWhenTypeMatchesName: !readUnifiedConfig(InlayHintSettingNames.variableTypesSuppressWhenTypeMatchesName, true, { scope, fallbackSection }), + includeInlayPropertyDeclarationTypeHints: readUnifiedConfig(InlayHintSettingNames.propertyDeclarationTypesEnabled, false, { scope, fallbackSection }), + includeInlayFunctionLikeReturnTypeHints: readUnifiedConfig(InlayHintSettingNames.functionLikeReturnTypesEnabled, false, { scope, fallbackSection }), + includeInlayEnumMemberValueHints: readUnifiedConfig(InlayHintSettingNames.enumMemberValuesEnabled, false, { scope, fallbackSection }), } as const; } -function getInlayParameterNameHintsPreference(config: vscode.WorkspaceConfiguration) { - switch (config.get('inlayHints.parameterNames.enabled')) { +function getInlayParameterNameHintsPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { + switch (readUnifiedConfig(InlayHintSettingNames.parameterNamesEnabled, 'none', { scope, fallbackSection })) { case 'none': return 'none'; case 'literals': return 'literals'; case 'all': return 'all'; diff --git a/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts b/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts index 4fa38e4986b..16bf7dd62db 100644 --- a/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts +++ b/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts @@ -11,20 +11,13 @@ import { API } from '../tsServer/api'; import type * as Proto from '../tsServer/protocol/protocol'; import { Location, Position } from '../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../typescriptService'; +import { unifiedConfigSection } from '../utils/configuration'; import { Disposable } from '../utils/dispose'; import FileConfigurationManager, { InlayHintSettingNames, getInlayHintsPreferences } from './fileConfigurationManager'; import { conditionalRegistration, requireMinVersion, requireSomeCapability } from './util/dependentRegistration'; -const inlayHintSettingNames = Object.freeze([ - InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, - InlayHintSettingNames.parameterNamesEnabled, - InlayHintSettingNames.variableTypesEnabled, - InlayHintSettingNames.variableTypesSuppressWhenTypeMatchesName, - InlayHintSettingNames.propertyDeclarationTypesEnabled, - InlayHintSettingNames.functionLikeReturnTypesEnabled, - InlayHintSettingNames.enumMemberValuesEnabled, -]); +const inlayHintSettingNames = Object.values(InlayHintSettingNames); class TypeScriptInlayHintsProvider extends Disposable implements vscode.InlayHintsProvider { @@ -44,7 +37,10 @@ class TypeScriptInlayHintsProvider extends Disposable implements vscode.InlayHin super(); this._register(vscode.workspace.onDidChangeConfiguration(e => { - if (inlayHintSettingNames.some(settingName => e.affectsConfiguration(language.id + '.' + settingName))) { + if (inlayHintSettingNames.some(settingName => + e.affectsConfiguration(unifiedConfigSection + '.' + settingName) || + e.affectsConfiguration(language.id + '.' + settingName) + )) { this._onDidChangeInlayHints.fire(); } })); @@ -131,8 +127,7 @@ function fromProtocolInlayHintKind(kind: Proto.InlayHintKind): vscode.InlayHintK } function areInlayHintsEnabledForFile(language: LanguageDescription, document: vscode.TextDocument) { - const config = vscode.workspace.getConfiguration(language.id, document); - const preferences = getInlayHintsPreferences(config); + const preferences = getInlayHintsPreferences(document, language.id); return preferences.includeInlayParameterNameHints === 'literals' || preferences.includeInlayParameterNameHints === 'all' || diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf index ff46f210b8a..a3098e1715c 100644 Binary files a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/base/browser/ui/grid/grid.ts b/src/vs/base/browser/ui/grid/grid.ts index 60b2666cf26..b343a0e3bc2 100644 --- a/src/vs/base/browser/ui/grid/grid.ts +++ b/src/vs/base/browser/ui/grid/grid.ts @@ -9,9 +9,10 @@ import { Event } from '../../../common/event.js'; import { Disposable } from '../../../common/lifecycle.js'; import './gridview.css'; import { Box, GridView, IGridViewOptions, IGridViewStyles, IView as IGridViewView, IViewSize, orthogonal, Sizing as GridViewSizing, GridLocation } from './gridview.js'; -import type { SplitView, AutoSizing as SplitViewAutoSizing } from '../splitview/splitview.js'; +import type { SplitView, AutoSizing as SplitViewAutoSizing, IViewVisibilityAnimationOptions } from '../splitview/splitview.js'; export type { IViewSize }; +export type { IViewVisibilityAnimationOptions } from '../splitview/splitview.js'; export { LayoutPriority, Orientation, orthogonal } from './gridview.js'; export const enum Direction { @@ -649,10 +650,12 @@ export class Grid extends Disposable { * Set the visibility state of a {@link IView view}. * * @param view The {@link IView view}. + * @param visible Whether the view should be visible. + * @param animation Optional animation options. */ - setViewVisible(view: T, visible: boolean): void { + setViewVisible(view: T, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { const location = this.getViewLocation(view); - this.gridview.setViewVisible(location, visible); + this.gridview.setViewVisible(location, visible, animation); } /** diff --git a/src/vs/base/browser/ui/grid/gridview.ts b/src/vs/base/browser/ui/grid/gridview.ts index 46821d6fcb8..f7137f0342c 100644 --- a/src/vs/base/browser/ui/grid/gridview.ts +++ b/src/vs/base/browser/ui/grid/gridview.ts @@ -5,7 +5,7 @@ import { $ } from '../../dom.js'; import { IBoundarySashes, Orientation, Sash } from '../sash/sash.js'; -import { DistributeSizing, ISplitViewStyles, IView as ISplitView, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js'; +import { DistributeSizing, ISplitViewStyles, IView as ISplitView, IViewVisibilityAnimationOptions, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js'; import { equals as arrayEquals, tail } from '../../../common/arrays.js'; import { Color } from '../../../common/color.js'; import { Emitter, Event, Relay } from '../../../common/event.js'; @@ -615,7 +615,7 @@ class BranchNode implements ISplitView, IDisposable { return this.splitview.isViewVisible(index); } - setChildVisible(index: number, visible: boolean): void { + setChildVisible(index: number, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { index = validateIndex(index, this.children.length); if (this.splitview.isViewVisible(index) === visible) { @@ -623,7 +623,7 @@ class BranchNode implements ISplitView, IDisposable { } const wereAllChildrenHidden = this.splitview.contentSize === 0; - this.splitview.setViewVisible(index, visible); + this.splitview.setViewVisible(index, visible, animation); const areAllChildrenHidden = this.splitview.contentSize === 0; // If all children are hidden then the parent should hide the entire splitview @@ -1661,7 +1661,7 @@ export class GridView implements IDisposable { * * @param location The {@link GridLocation location} of the view. */ - setViewVisible(location: GridLocation, visible: boolean): void { + setViewVisible(location: GridLocation, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { if (this.hasMaximizedView()) { this.exitMaximizedView(); return; @@ -1674,7 +1674,7 @@ export class GridView implements IDisposable { throw new Error('Invalid from location'); } - parent.setChildVisible(index, visible); + parent.setChildVisible(index, visible, animation); } /** diff --git a/extensions/mermaid-chat-features/extension-browser.webpack.config.js b/src/vs/base/browser/ui/motion/motion.css similarity index 62% rename from extensions/mermaid-chat-features/extension-browser.webpack.config.js rename to src/vs/base/browser/ui/motion/motion.css index b758f2d8155..69e257be2d3 100644 --- a/extensions/mermaid-chat-features/extension-browser.webpack.config.js +++ b/src/vs/base/browser/ui/motion/motion.css @@ -2,12 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - } -}); +/* Utility class applied during panel animations to prevent content overflow */ +.monaco-split-view2 > .split-view-container > .split-view-view.motion-animating { + overflow: hidden; +} diff --git a/src/vs/base/browser/ui/motion/motion.ts b/src/vs/base/browser/ui/motion/motion.ts new file mode 100644 index 00000000000..c2e8a045d41 --- /dev/null +++ b/src/vs/base/browser/ui/motion/motion.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './motion.css'; + +//#region Easing Curves + +/** + * A pre-parsed cubic bezier easing curve that can be evaluated directly + * without reparsing a CSS string on every frame. + * + * Given control points `(x1, y1)` and `(x2, y2)` (the CSS `cubic-bezier` + * parameters), {@link solve} finds the bezier parameter `u` such that + * `Bx(u) = t` using Newton's method, then returns `By(u)`. + */ +export class CubicBezierCurve { + + constructor( + readonly x1: number, + readonly y1: number, + readonly x2: number, + readonly y2: number, + ) { } + + /** + * Evaluate the curve at time `t` (0-1), returning the eased value. + */ + solve(t: number): number { + if (t <= 0) { + return 0; + } + if (t >= 1) { + return 1; + } + + // Newton's method to find u where Bx(u) = t + let u = t; // initial guess + for (let i = 0; i < 8; i++) { + const currentX = bezierComponent(u, this.x1, this.x2); + const error = currentX - t; + if (Math.abs(error) < 1e-6) { + break; + } + const dx = bezierComponentDerivative(u, this.x1, this.x2); + if (Math.abs(dx) < 1e-6) { + break; + } + u -= error / dx; + } + + u = Math.max(0, Math.min(1, u)); + return bezierComponent(u, this.y1, this.y2); + } + + /** + * Returns the CSS `cubic-bezier(…)` string representation, for use in + * CSS `transition` or `animation` properties. + */ + toCssString(): string { + return `cubic-bezier(${this.x1}, ${this.y1}, ${this.x2}, ${this.y2})`; + } +} + +/** + * Fluent 2 ease-out curve - default for entrances and expansions. + * Starts fast and decelerates to a stop. + */ +export const EASE_OUT = new CubicBezierCurve(0.1, 0.9, 0.2, 1); + +/** + * Fluent 2 ease-in curve - for exits and collapses. + * Starts slow and accelerates out. + */ +export const EASE_IN = new CubicBezierCurve(0.9, 0.1, 1, 0.2); + +//#endregion + +//#region Cubic Bezier Evaluation + +/** + * Parses a CSS `cubic-bezier(x1, y1, x2, y2)` string into a + * {@link CubicBezierCurve}. Returns a linear curve on parse failure. + */ +export function parseCubicBezier(css: string): CubicBezierCurve { + const match = css.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/); + if (!match) { + return new CubicBezierCurve(0, 0, 1, 1); + } + return new CubicBezierCurve(parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])); +} + +/** Evaluates one component of a cubic bezier: B(u) with control points p1, p2, endpoints 0 and 1. */ +function bezierComponent(u: number, p1: number, p2: number): number { + // B(u) = 3(1-u)^2*u*p1 + 3(1-u)*u^2*p2 + u^3 + const oneMinusU = 1 - u; + return 3 * oneMinusU * oneMinusU * u * p1 + 3 * oneMinusU * u * u * p2 + u * u * u; +} + +/** First derivative of a bezier component: B'(u). */ +function bezierComponentDerivative(u: number, p1: number, p2: number): number { + // B'(u) = 3(1-u)^2*p1 + 6(1-u)*u*(p2-p1) + 3*u^2*(1-p2) + const oneMinusU = 1 - u; + return 3 * oneMinusU * oneMinusU * p1 + 6 * oneMinusU * u * (p2 - p1) + 3 * u * u * (1 - p2); +} + +//#endregion + +//#region Duration Scaling + +/** + * Reference pixel distance at which the base duration constants apply. + * Duration scales linearly: a 600px animation takes twice as long as a 300px + * one, keeping perceived velocity constant. + */ +const REFERENCE_DISTANCE = 300; + +/** Minimum animation duration in milliseconds (avoids sub-frame flickers). */ +const MIN_DURATION = 50; + +/** Maximum animation duration in milliseconds (avoids sluggish feel). */ +const MAX_DURATION = 300; + +/** + * Scales a base animation duration proportionally to the pixel distance + * being animated, so that perceived velocity stays constant regardless of + * panel width. + * + * @param baseDuration The duration (ms) that applies at {@link REFERENCE_DISTANCE} pixels. + * @param pixelDistance The actual number of pixels the view will resize. + * @returns The scaled duration, clamped to [{@link MIN_DURATION}, {@link MAX_DURATION}]. + */ +export function scaleDuration(baseDuration: number, pixelDistance: number): number { + if (pixelDistance <= 0) { + return baseDuration; + } + const scaled = baseDuration * (pixelDistance / REFERENCE_DISTANCE); + return Math.round(Math.max(MIN_DURATION, Math.min(MAX_DURATION, scaled))); +} + +//#endregion + +//#region Utility Functions + +/** + * Checks whether motion is reduced by looking for the `monaco-reduce-motion` + * class on an ancestor element. This integrates with VS Code's existing + * accessibility infrastructure in {@link AccessibilityService}. + */ +export function isMotionReduced(element: HTMLElement): boolean { + return element.closest('.monaco-reduce-motion') !== null; +} + +//#endregion diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts index 35f2724c1a8..c6ac50b2ded 100644 --- a/src/vs/base/browser/ui/splitview/splitview.ts +++ b/src/vs/base/browser/ui/splitview/splitview.ts @@ -8,15 +8,41 @@ import { DomEmitter } from '../../event.js'; import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from '../sash/sash.js'; import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js'; import { pushToEnd, pushToStart, range } from '../../../common/arrays.js'; +import { CancellationToken } from '../../../common/cancellation.js'; import { Color } from '../../../common/color.js'; import { Emitter, Event } from '../../../common/event.js'; import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js'; import { clamp } from '../../../common/numbers.js'; import { Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js'; import * as types from '../../../common/types.js'; +import { CubicBezierCurve, isMotionReduced, scaleDuration } from '../motion/motion.js'; import './splitview.css'; export { Orientation } from '../sash/sash.js'; +/** + * Options for animating a view visibility change in a {@link SplitView}. + */ +export interface IViewVisibilityAnimationOptions { + + /** Transition duration in milliseconds. */ + readonly duration: number; + + /** The easing curve applied to the animation. */ + readonly easing: CubicBezierCurve; + + /** + * Optional callback invoked when the animation finishes naturally. + * NOT called if the animation is cancelled via the {@link token}. + */ + readonly onComplete?: () => void; + + /** + * A cancellation token that allows the caller to stop the animation. + * When cancellation is requested the animation snaps to its final state. + */ + readonly token: CancellationToken; +} + export interface ISplitViewStyles { readonly separatorBorder: Color; } @@ -802,14 +828,38 @@ export class SplitView= this.viewItems.length) { throw new Error('Index out of bounds'); } + // Cancel any in-flight animation before changing visibility. + // An animated visibility change interpolates ALL view sizes each + // frame, so a concurrent change on a different view would be + // overwritten on the next frame. Snapping first prevents that. + this._cleanupMotion?.(); + this._cleanupMotion = undefined; + + if (animation && !animation.token.isCancellationRequested && !isMotionReduced(this.el) && this.viewItems[index].visible !== visible) { + this._setViewVisibleAnimated(index, visible, animation); + } else { + this._setViewVisibleInstant(index, visible); + } + } + + /** + * Apply the visibility change to the model without animation. + */ + private _setViewVisibleInstant(index: number, visible: boolean): void { const viewItem = this.viewItems[index]; viewItem.setVisible(visible); @@ -818,6 +868,146 @@ export class SplitView v.size); + + // 2. Apply the target visibility to the model instantly. + // This computes final sizes, fires events, updates sashes, etc. + this._setViewVisibleInstant(index, visible); + + // 3. Snapshot sizes AFTER the visibility change (the animation end state) + const finalSizes = this.viewItems.map(v => v.size); + + // 4. Restore start sizes so we can animate FROM them + for (let i = 0; i < this.viewItems.length; i++) { + this.viewItems[i].size = startSizes[i]; + } + + // 5. For hiding: the target container lost .visible class (→ display:none). + // Restore it so content stays visible during the animation. + if (!visible) { + container.classList.add('visible'); + } + + // 6. Clip overflow on the target container while it shrinks. + // Only apply for HIDE animations - for SHOW, we leave overflow alone + // so that box-shadow / visual effects on the child Part are not clipped + // by the parent container during the animation. + if (!visible) { + container.style.overflow = 'hidden'; + } + + // 6b. Set initial opacity for fade effect + container.style.opacity = visible ? '0' : '1'; + + // 7. Scale duration based on pixel distance for consistent perceived velocity + const pixelDistance = Math.abs(finalSizes[index] - startSizes[index]); + const duration = scaleDuration(baseDuration, pixelDistance); + + // 8. Render the start state + this.layoutViews(); + + // 9. Easing curve is pre-parsed - ready for JS evaluation + + // Helper: snap all sizes to final state and clean up + const applyFinalState = () => { + for (let i = 0; i < this.viewItems.length; i++) { + this.viewItems[i].size = finalSizes[i]; + } + container.style.opacity = ''; + if (!visible) { + container.classList.remove('visible'); + container.style.overflow = ''; + } + this.layoutViews(); + this.saveProportions(); + }; + + const cleanup = (completed: boolean) => { + if (disposed) { + return; + } + disposed = true; + tokenListener.dispose(); + if (rafId !== undefined) { + window.cancelAnimationFrame(rafId); + rafId = undefined; + } + applyFinalState(); + this._cleanupMotion = undefined; + if (completed) { + onComplete?.(); + } + }; + this._cleanupMotion = () => cleanup(false); + + // Listen to the cancellation token so the caller can stop the animation + const tokenListener = token.onCancellationRequested(() => cleanup(false)); + + // 10. Animate via requestAnimationFrame + const startTime = performance.now(); + const totalSize = this.size; + + const animate = () => { + if (disposed) { + return; + } + + const elapsed = performance.now() - startTime; + const t = Math.min(elapsed / duration, 1); + const easedT = easing.solve(t); + + // Interpolate opacity for fade effect + container.style.opacity = String(visible ? easedT : 1 - easedT); + + // Interpolate all view sizes + let runningTotal = 0; + for (let i = 0; i < this.viewItems.length; i++) { + if (i === this.viewItems.length - 1) { + // Last item absorbs rounding errors to maintain total = this.size + this.viewItems[i].size = totalSize - runningTotal; + } else { + const size = Math.round( + startSizes[i] + (finalSizes[i] - startSizes[i]) * easedT + ); + this.viewItems[i].size = size; + runningTotal += size; + } + } + + this.layoutViews(); + + if (t < 1) { + rafId = window.requestAnimationFrame(animate); + } else { + cleanup(true); + } + }; + + rafId = window.requestAnimationFrame(animate); + } + + private _cleanupMotion: (() => void) | undefined; + /** * Returns the {@link IView view}'s size previously to being hidden. * diff --git a/src/vs/base/parts/ipc/node/ipc.net.ts b/src/vs/base/parts/ipc/node/ipc.net.ts index 9a508286e23..5ada7242262 100644 --- a/src/vs/base/parts/ipc/node/ipc.net.ts +++ b/src/vs/base/parts/ipc/node/ipc.net.ts @@ -318,6 +318,10 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT return this._flowManager.recordedInflateBytes; } + public setRecordInflateBytes(record: boolean): void { + this._flowManager.setRecordInflateBytes(record); + } + public traceSocketEvent(type: SocketDiagnosticsEventType, data?: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView | unknown): void { this.socket.traceSocketEvent(type, data); } @@ -598,6 +602,10 @@ class WebSocketFlowManager extends Disposable { return VSBuffer.alloc(0); } + public setRecordInflateBytes(record: boolean): void { + this._zlibInflateStream?.setRecordInflateBytes(record); + } + constructor( private readonly _tracer: ISocketTracer, permessageDeflate: boolean, @@ -714,6 +722,7 @@ class ZlibInflateStream extends Disposable { private readonly _zlibInflate: InflateRaw; private readonly _recordedInflateBytes: VSBuffer[] = []; private readonly _pendingInflateData: VSBuffer[] = []; + private _recordInflateBytes: boolean; public get recordedInflateBytes(): VSBuffer { if (this._recordInflateBytes) { @@ -724,11 +733,12 @@ class ZlibInflateStream extends Disposable { constructor( private readonly _tracer: ISocketTracer, - private readonly _recordInflateBytes: boolean, + recordInflateBytes: boolean, inflateBytes: VSBuffer | null, options: ZlibOptions ) { super(); + this._recordInflateBytes = recordInflateBytes; this._zlibInflate = createInflateRaw(options); this._zlibInflate.on('error', (err: Error) => { this._tracer.traceSocketEvent(SocketDiagnosticsEventType.zlibInflateError, { message: err?.message, code: (err as NodeJS.ErrnoException)?.code }); @@ -756,6 +766,13 @@ class ZlibInflateStream extends Disposable { this._zlibInflate.write(buffer.buffer); } + public setRecordInflateBytes(record: boolean): void { + this._recordInflateBytes = record; + if (!record) { + this._recordedInflateBytes.length = 0; + } + } + public flush(callback: (data: VSBuffer) => void): void { this._zlibInflate.flush(() => { this._tracer.traceSocketEvent(SocketDiagnosticsEventType.zlibInflateFlushFired); @@ -764,6 +781,17 @@ class ZlibInflateStream extends Disposable { callback(data); }); } + + public override dispose(): void { + this._recordedInflateBytes.length = 0; + this._pendingInflateData.length = 0; + try { + this._zlibInflate.close(); + } catch { + // ignore errors while disposing + } + super.dispose(); + } } class ZlibDeflateStream extends Disposable { @@ -812,6 +840,16 @@ class ZlibDeflateStream extends Disposable { callback(data); }); } + + public override dispose(): void { + this._pendingDeflateData.length = 0; + try { + this._zlibDeflate.close(); + } catch { + // ignore errors while disposing + } + super.dispose(); + } } function unmask(buffer: VSBuffer, mask: number): void { diff --git a/src/vs/base/parts/ipc/test/node/ipc.net.test.ts b/src/vs/base/parts/ipc/test/node/ipc.net.test.ts index a1bc9a5749a..6c96decef45 100644 --- a/src/vs/base/parts/ipc/test/node/ipc.net.test.ts +++ b/src/vs/base/parts/ipc/test/node/ipc.net.test.ts @@ -711,6 +711,47 @@ suite('WebSocketNodeSocket', () => { assert.deepStrictEqual(actual, 'Hello'); }); + test('setRecordInflateBytes(false) clears and stops recording', async () => { + const disposables = new DisposableStore(); + const socket = disposables.add(new FakeNodeSocket()); + // eslint-disable-next-line local/code-no-any-casts + const webSocket = disposables.add(new WebSocketNodeSocket(socket, true, null, true)); + + const compressedHelloFrame = [0xc1, 0x07, 0xf2, 0x48, 0xcd, 0xc9, 0xc9, 0x07, 0x00]; + const waitForOneData = () => new Promise(resolve => { + const d = webSocket.onData(data => { + d.dispose(); + resolve(data); + }); + }); + + const firstPromise = waitForOneData(); + socket.fireData(compressedHelloFrame); + const first = await firstPromise; + assert.strictEqual(fromCharCodeArray(fromUint8Array(first.buffer)), 'Hello'); + assert.ok(webSocket.recordedInflateBytes.byteLength > 0); + + webSocket.setRecordInflateBytes(false); + assert.strictEqual(webSocket.recordedInflateBytes.byteLength, 0); + + const secondPromise = waitForOneData(); + socket.fireData(compressedHelloFrame); + const second = await secondPromise; + assert.strictEqual(fromCharCodeArray(fromUint8Array(second.buffer)), 'Hello'); + assert.strictEqual(webSocket.recordedInflateBytes.byteLength, 0); + + webSocket.setRecordInflateBytes(true); + assert.strictEqual(webSocket.recordedInflateBytes.byteLength, 0); + + const thirdPromise = waitForOneData(); + socket.fireData(compressedHelloFrame); + const third = await thirdPromise; + assert.strictEqual(fromCharCodeArray(fromUint8Array(third.buffer)), 'Hello'); + assert.ok(webSocket.recordedInflateBytes.byteLength > 0); + + disposables.dispose(); + }); + test('A fragmented compressed text message', async () => { // contains "Hello" const frames = [ // contains "Hello" diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index bd509719a3c..9afd0964022 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -525,3 +525,35 @@ outline: 1px solid var(--vscode-list-focusOutline) !important; outline-offset: -1px; } + +/* Entrance animation */ +@keyframes quick-input-entrance { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.quick-input-widget.animating-entrance { + animation: quick-input-entrance 150ms cubic-bezier(0.1, 0.9, 0.2, 1) forwards; +} + +/* Exit animation */ +@keyframes quick-input-exit { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +.quick-input-widget.animating-exit { + animation: quick-input-exit 50ms cubic-bezier(0.9, 0.1, 1, 0.2) forwards; +} diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 8e5283ef9ad..11964ea5a30 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -37,6 +37,7 @@ import { TriStateCheckbox, createToggleActionViewItemProvider } from '../../../b import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; +import { isMotionReduced } from '../../../base/browser/ui/motion/motion.js'; import { AnchorAlignment, AnchorPosition, layout2d } from '../../../base/common/layout.js'; import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js'; @@ -80,6 +81,7 @@ export class QuickInputController extends Disposable { private viewState: QuickInputViewState | undefined; private dndController: QuickInputDragAndDropController | undefined; + private _cancelExitAnimation: (() => void) | undefined; private readonly inQuickInputContext: IContextKey; private readonly quickInputTypeContext: IContextKey; @@ -711,12 +713,26 @@ export class QuickInputController extends Disposable { const backKeybindingLabel = this.options.backKeybindingLabel(); backButton.tooltip = backKeybindingLabel ? localize('quickInput.backWithKeybinding', "Back ({0})", backKeybindingLabel) : localize('quickInput.back', "Back"); + const wasVisible = ui.container.style.display !== 'none'; ui.container.style.display = ''; + // Cancel any in-flight exit animation that would set display:none + this._cancelExitAnimation?.(); + this._cancelExitAnimation = undefined; this.updateLayout(); this.dndController?.setEnabled(!controller.anchor); this.dndController?.layoutContainer(); ui.inputBox.setFocus(); this.quickInputTypeContext.set(controller.type); + + // Animate entrance: fade in + slide down (only when first appearing) + if (!wasVisible && !isMotionReduced(ui.container)) { + ui.container.classList.add('animating-entrance'); + const onAnimationEnd = () => { + ui.container.classList.remove('animating-entrance'); + ui.container.removeEventListener('animationend', onAnimationEnd); + }; + ui.container.addEventListener('animationend', onAnimationEnd); + } } isVisible(): boolean { @@ -783,7 +799,24 @@ export class QuickInputController extends Disposable { this.controller = null; this.onHideEmitter.fire(); if (container) { - container.style.display = 'none'; + // Animate exit: fade out + slide up (faster than open) + if (!isMotionReduced(container)) { + container.classList.add('animating-exit'); + const cleanupAnimation = () => { + container.classList.remove('animating-exit'); + container.removeEventListener('animationend', onAnimationEnd); + this._cancelExitAnimation = undefined; + }; + const onAnimationEnd = () => { + // Set display after animation completes to actually hide the element + container.style.display = 'none'; + cleanupAnimation(); + }; + this._cancelExitAnimation = cleanupAnimation; + container.addEventListener('animationend', onAnimationEnd); + } else { + container.style.display = 'none'; + } } if (!focusChanged) { let currentElement = this.previousFocusElement; diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 968009e20e6..707bfa7fc0f 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -244,7 +244,11 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess return undefined; } catch (err) { this._logService.trace('node-pty.node-pty.IPty#spawn native exception', err); - return { message: `A native exception occurred during launch (${err.message})` }; + const errorMessage = err.message; + if (errorMessage?.includes('Cannot launch conpty')) { + return { message: localize('conptyLaunchFailed', "A native exception occurred during launch (Cannot launch conpty). Winpty has been removed, see {0} for more details. You can also try enabling the `{1}` setting.", 'https://code.visualstudio.com/updates/v1_109#_removal-of-winpty-support', 'terminal.integrated.windowsUseConptyDll') }; + } + return { message: `A native exception occurred during launch (${errorMessage})` }; } } diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index ed54d90f383..05c4489758b 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as os from 'os'; import { IntervalTimer, timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { isMacintosh } from '../../../base/common/platform.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; @@ -29,6 +31,23 @@ export function createUpdateURL(baseUpdateUrl: string, platform: string, quality return url.toString(); } +/** + * Builds common headers for macOS update requests, including those issued + * via Electron's auto-updater (e.g. setFeedURL({ url, headers })) and + * manual HTTP requests that bypass the auto-updater. On macOS, this includes + * the Darwin kernel version which the update server uses for EOL detection. + */ +export function getUpdateRequestHeaders(productVersion: string): Record | undefined { + if (isMacintosh) { + const darwinVersion = os.release(); + return { + 'User-Agent': `Code/${productVersion} Darwin/${darwinVersion}` + }; + } + + return undefined; +} + export type UpdateErrorClassification = { owner: 'joaomoreno'; messageHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The hash of the error message.' }; @@ -288,11 +307,16 @@ export abstract class AbstractUpdateService implements IUpdateService { return undefined; } + const headers = getUpdateRequestHeaders(this.productService.version); + this.logService.trace('update#isLatestVersion() - checking update server', { url, headers }); + try { - const context = await this.requestService.request({ url }, token); + const context = await this.requestService.request({ url, headers }, token); + const statusCode = context.res.statusCode; + this.logService.trace('update#isLatestVersion() - response', { statusCode }); // The update server replies with 204 (No Content) when no // update is available - that's all we want to know. - return context.res.statusCode === 204; + return statusCode === 204; } catch (error) { this.logService.error('update#isLatestVersion(): failed to check for updates'); diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index fe133e702b9..e65a9823839 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -18,13 +18,14 @@ import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; -import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { private readonly disposables = new DisposableStore(); @memoize private get onRawError(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'error', (_, message) => message); } + @memoize private get onRawCheckingForUpdate(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'checking-for-update'); } @memoize private get onRawUpdateNotAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-not-available'); } @memoize private get onRawUpdateAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-available'); } @memoize private get onRawUpdateDownloaded(): Event { @@ -68,11 +69,16 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected override async initialize(): Promise { await super.initialize(); this.onRawError(this.onError, this, this.disposables); + this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); this.onRawUpdateAvailable(this.onUpdateAvailable, this, this.disposables); this.onRawUpdateDownloaded(this.onUpdateDownloaded, this, this.disposables); this.onRawUpdateNotAvailable(this.onUpdateNotAvailable, this, this.disposables); } + private onCheckingForUpdate(): void { + this.logService.trace('update#onCheckingForUpdate - Electron autoUpdater is checking for updates'); + } + private onError(err: string): void { this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); this.logService.error('UpdateService error:', err); @@ -85,8 +91,10 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { const assetID = this.productService.darwinUniversalAssetId ?? (process.arch === 'x64' ? 'darwin' : 'darwin-arm64'); const url = createUpdateURL(this.productService.updateUrl!, assetID, quality, commit, options); + const headers = getUpdateRequestHeaders(this.productService.version); try { - electron.autoUpdater.setFeedURL({ url }); + this.logService.trace('update#buildUpdateFeedUrl - setting feed URL for Electron autoUpdater', { url, assetID, quality, commit, headers }); + electron.autoUpdater.setFeedURL({ url, headers }); } catch (e) { // application is very likely not signed this.logService.error('Failed to set update feed URL', e); @@ -126,6 +134,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } + this.logService.trace('update#doCheckForUpdates - using Electron autoUpdater', { url, explicit, background }); electron.autoUpdater.checkForUpdates(); } @@ -134,20 +143,31 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau * Used when connection is metered to show update availability without downloading. */ private async checkForUpdateNoDownload(url: string): Promise { + const headers = getUpdateRequestHeaders(this.productService.version); + this.logService.trace('update#checkForUpdateNoDownload - checking update server', { url, headers }); + try { - const update = await asJson(await this.requestService.request({ url }, CancellationToken.None)); + const context = await this.requestService.request({ url, headers }, CancellationToken.None); + const statusCode = context.res.statusCode; + this.logService.trace('update#checkForUpdateNoDownload - response', { statusCode }); + + const update = await asJson(context); if (!update || !update.url || !update.version || !update.productVersion) { + this.logService.trace('update#checkForUpdateNoDownload - no update available'); this.setState(State.Idle(UpdateType.Archive)); } else { + this.logService.trace('update#checkForUpdateNoDownload - update available', { version: update.version, productVersion: update.productVersion }); this.setState(State.AvailableForDownload(update)); } } catch (err) { - this.logService.error(err); + this.logService.error('update#checkForUpdateNoDownload - failed to check for update', err); this.setState(State.Idle(UpdateType.Archive)); } } private onUpdateAvailable(): void { + this.logService.trace('update#onUpdateAvailable - Electron autoUpdater reported update available'); + if (this.state.type !== StateType.CheckingForUpdates && this.state.type !== StateType.Overwriting) { return; } @@ -167,6 +187,8 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau } private onUpdateNotAvailable(): void { + this.logService.trace('update#onUpdateNotAvailable - Electron autoUpdater reported no update available'); + if (this.state.type !== StateType.CheckingForUpdates) { return; } diff --git a/src/vs/server/node/extensionHostConnection.ts b/src/vs/server/node/extensionHostConnection.ts index 6ae4edd84b9..0daf9ee7031 100644 --- a/src/vs/server/node/extensionHostConnection.ts +++ b/src/vs/server/node/extensionHostConnection.ts @@ -94,6 +94,7 @@ class ConnectionData { skipWebSocketFrames = false; permessageDeflate = this.socket.permessageDeflate; inflateBytes = this.socket.recordedInflateBytes; + this.socket.setRecordInflateBytes(false); } return { @@ -133,6 +134,9 @@ export class ExtensionHostConnection extends Disposable { this._remoteAddress = remoteAddress; this._extensionHostProcess = null; this._connectionData = new ConnectionData(socket, initialDataChunk); + if (!this._canSendSocket && socket instanceof WebSocketNodeSocket) { + socket.setRecordInflateBytes(false); + } this._log(`New connection established.`); } @@ -209,6 +213,9 @@ export class ExtensionHostConnection extends Disposable { public acceptReconnection(remoteAddress: string, _socket: NodeSocket | WebSocketNodeSocket, initialDataChunk: VSBuffer): void { this._remoteAddress = remoteAddress; this._log(`The client has reconnected.`); + if (!this._canSendSocket && _socket instanceof WebSocketNodeSocket) { + _socket.setRecordInflateBytes(false); + } const connectionData = new ConnectionData(_socket, initialDataChunk); if (!this._extensionHostProcess) { diff --git a/src/vs/server/node/remoteExtensionHostAgentServer.ts b/src/vs/server/node/remoteExtensionHostAgentServer.ts index 269cc3878eb..da7e417cd5c 100644 --- a/src/vs/server/node/remoteExtensionHostAgentServer.ts +++ b/src/vs/server/node/remoteExtensionHostAgentServer.ts @@ -395,6 +395,9 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { if (msg.desiredConnectionType === ConnectionType.Management) { // This should become a management connection + if (socket instanceof WebSocketNodeSocket) { + socket.setRecordInflateBytes(false); + } if (isReconnection) { // This is a reconnection @@ -484,6 +487,9 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { } } else if (msg.desiredConnectionType === ConnectionType.Tunnel) { + if (socket instanceof WebSocketNodeSocket) { + socket.setRecordInflateBytes(false); + } const tunnelStartParams = msg.args; this._createTunnel(protocol, tunnelStartParams); diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index da9a664e813..47bff4e1d83 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -354,13 +354,9 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes this._onDidChangeChatSessionItems.fire(); } - updateItem(item: IChatSessionItem): void { - if (this._items.has(item.resource)) { - this._items.set(item.resource, item); - this._onDidChangeChatSessionItems.fire(); - } else { - console.warn(`Item with resource ${item.resource.toString()} does not exist. Skipping update.`); - } + addOrUpdateItem(item: IChatSessionItem): void { + this._items.set(item.resource, item); + this._onDidChangeChatSessionItems.fire(); } fireOnDidChangeChatSessionItems(): void { @@ -440,8 +436,17 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } + private getController(handle: number): MainThreadChatSessionItemController { + const registration = this._itemControllerRegistrations.get(handle); + if (!registration) { + throw new Error(`No chat session controller registered for handle ${handle}`); + } + return registration.controller; + } + $onDidChangeChatSessionItems(handle: number): void { - this._itemControllerRegistrations.get(handle)?.controller.fireOnDidChangeChatSessionItems(); + const controller = this.getController(handle); + controller.fireOnDidChangeChatSessionItems(); } private async _resolveSessionItem(item: Dto): Promise { @@ -474,31 +479,20 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat }; } - async $setChatSessionItems(handle: number, items: Dto[]): Promise { - const registration = this._itemControllerRegistrations.get(handle); - if (!registration) { - this._logService.warn(`No chat session controller registered for handle ${handle}`); - return; - } - + async $setChatSessionItems(controllerHandle: number, items: Dto[]): Promise { + const controller = this.getController(controllerHandle); const resolvedItems = await Promise.all(items.map(item => this._resolveSessionItem(item))); - registration.controller.setItems(resolvedItems); + controller.setItems(resolvedItems); } - async $updateChatSessionItem(controllerHandle: number, item: Dto): Promise { - const registration = this._itemControllerRegistrations.get(controllerHandle); - if (!registration) { - this._logService.warn(`No chat session controller registered for handle ${controllerHandle}`); - return; - } - + async $addOrUpdateChatSessionItem(controllerHandle: number, item: Dto): Promise { + const controller = this.getController(controllerHandle); const resolvedItem = await this._resolveSessionItem(item); - registration.controller.updateItem(resolvedItem); + controller.addOrUpdateItem(resolvedItem); } $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { const sessionResource = URI.revive(sessionResourceComponents); - this._chatSessionsService.notifySessionOptionsChange(sessionResource, updates); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 422aa1b5827..d12f99c0051 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3418,7 +3418,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $registerChatSessionItemController(handle: number, chatSessionType: string): void; $unregisterChatSessionItemController(handle: number): void; $setChatSessionItems(handle: number, items: Dto[]): Promise; - $updateChatSessionItem(handle: number, item: Dto): Promise; + $addOrUpdateChatSessionItem(handle: number, item: Dto): Promise; $onDidChangeChatSessionItems(handle: number): void; $onDidCommitChatSessionItem(handle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; diff --git a/src/vs/workbench/api/common/extHostChatContext.ts b/src/vs/workbench/api/common/extHostChatContext.ts index f0c6509f8a5..74a9fab7e5f 100644 --- a/src/vs/workbench/api/common/extHostChatContext.ts +++ b/src/vs/workbench/api/common/extHostChatContext.ts @@ -50,7 +50,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext throw new Error('Workspace context provider not found'); } const provider = entry.provider as vscode.ChatWorkspaceContextProvider; - const result = (await provider.provideWorkspaceChatContext(token)) ?? (await provider.provideChatContext?.(token)) ?? []; + const result = (await provider.provideWorkspaceChatContext?.(token)) ?? (await provider.provideChatContext?.(token)) ?? []; return this._convertItems(handle, result); } @@ -63,7 +63,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext throw new Error('Explicit context provider not found'); } const provider = entry.provider as vscode.ChatExplicitContextProvider; - const result = (await provider.provideExplicitChatContext(token)) ?? (await provider.provideChatContext?.(token)) ?? []; + const result = (await provider.provideExplicitChatContext?.(token)) ?? (await provider.provideChatContext?.(token)) ?? []; return this._convertItems(handle, result); } @@ -77,7 +77,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (!extItem) { throw new Error('Chat context item not found'); } - return this._doResolve((provider.resolveExplicitChatContext ?? provider.resolveChatContext).bind(provider), context, extItem, token); + return this._doResolve((provider.resolveExplicitChatContext ?? provider.resolveChatContext)?.bind(provider), context, extItem, token); } // Resource context provider methods @@ -89,7 +89,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext } const provider = entry.provider as vscode.ChatResourceContextProvider; - const result = (await provider.provideResourceChatContext({ resource: URI.revive(options.resource) }, token)) ?? (await provider.provideChatContext?.({ resource: URI.revive(options.resource) }, token)); + const result = (await provider.provideResourceChatContext?.({ resource: URI.revive(options.resource) }, token)) ?? (await provider.provideChatContext?.({ resource: URI.revive(options.resource) }, token)); if (!result) { return undefined; } @@ -109,7 +109,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext command: result.command ? { id: result.command.command } : undefined }; if (options.withValue && !item.value) { - const resolved = await (provider.resolveResourceChatContext ?? provider.resolveChatContext).bind(provider)(result, token); + const resolved = await (provider.resolveResourceChatContext ?? provider.resolveChatContext)?.bind(provider)(result, token); item.value = resolved?.value; item.tooltip = resolved?.tooltip ? MarkdownString.from(resolved.tooltip) : item.tooltip; } @@ -127,7 +127,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (!extItem) { throw new Error('Chat context item not found'); } - return this._doResolve((provider.resolveResourceChatContext ?? provider.resolveChatContext).bind(provider), context, extItem, token); + return this._doResolve((provider.resolveResourceChatContext ?? provider.resolveChatContext)?.bind(provider), context, extItem, token); } // Command execution @@ -315,7 +315,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext return; } const provideWorkspaceContext = async () => { - const workspaceContexts = await provider.provideWorkspaceChatContext(CancellationToken.None); + const workspaceContexts = (await provider.provideWorkspaceChatContext?.(CancellationToken.None) ?? await provider.provideChatContext?.(CancellationToken.None)); const resolvedContexts = this._convertItems(handle, workspaceContexts ?? []); return this._proxy.$updateWorkspaceContextItems(handle, resolvedContexts); }; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index febaa3a413d..8868ad66110 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -176,12 +176,17 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } } +interface SessionCollectionListeners { + onItemsChanged(): void; + onItemAddedOrUpdated(item: vscode.ChatSessionItem): void; +} + class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { readonly #items = new ResourceMap(); - #onItemsChanged: () => void; + readonly #callbacks: SessionCollectionListeners; - constructor(onItemsChanged: () => void) { - this.#onItemsChanged = onItemsChanged; + constructor(callbacks: SessionCollectionListeners) { + this.#callbacks = callbacks; } get size(): number { @@ -197,7 +202,7 @@ class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection for (const item of items) { this.#items.set(item.resource, item); } - this.#onItemsChanged(); + this.#callbacks.onItemsChanged(); } forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { @@ -207,13 +212,20 @@ class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection } add(item: vscode.ChatSessionItem): void { + const existing = this.#items.get(item.resource); + if (existing && existing === item) { + // We're adding the same item again + return; + } + this.#items.set(item.resource, item); - this.#onItemsChanged(); + this.#callbacks.onItemAddedOrUpdated(item); } delete(resource: vscode.Uri): void { - this.#items.delete(resource); - this.#onItemsChanged(); + if (this.#items.delete(resource)) { + this.#callbacks.onItemsChanged(); + } } get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { @@ -324,8 +336,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - const collection = new ChatSessionItemCollectionImpl(() => { + const collection = new ChatSessionItemCollectionImpl({ // Noop for providers + onItemsChanged: () => { }, + onItemAddedOrUpdated: () => { } }); // Helper to push items to main thread @@ -361,7 +375,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio if (provider.onDidChangeChatSessionItems) { disposables.add(provider.onDidChangeChatSessionItems(() => { this._logService.trace(`ExtHostChatSessions. Provider items changed for ${chatSessionType}`); - this._proxy.$onDidChangeChatSessionItems(handle); + // When a provider fires this, we treat it the same as triggering a refresh in the new controller based model. + // This is because with providers, firing this event would signal that `provide` should be called again. + // With controllers, it instead signals that you should read the current items again. + controller.refreshHandler(CancellationToken.None); })); } @@ -397,7 +414,12 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio void this._proxy.$setChatSessionItems(controllerHandle, items); }; - const collection = new ChatSessionItemCollectionImpl(onItemsChanged); + const collection = new ChatSessionItemCollectionImpl({ + onItemsChanged, + onItemAddedOrUpdated: (item: vscode.ChatSessionItem) => { + void this._proxy.$addOrUpdateChatSessionItem(controllerHandle, typeConvert.ChatSessionItem.from(item)); + } + }); const controller = Object.freeze({ id, @@ -417,7 +439,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } const item = new ChatSessionItemImpl(resource, label, () => { - void this._proxy.$updateChatSessionItem(controllerHandle, typeConvert.ChatSessionItem.from(item)); + // Make sure the item really is in the collection. If not we don't need to transmit it to the main thread yet + if (collection.get(resource) === item) { + void this._proxy.$addOrUpdateChatSessionItem(controllerHandle, typeConvert.ChatSessionItem.from(item)); + } }); return item; }, diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index 52eaf027866..eecdf625b00 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -150,6 +150,7 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { let _executeHandler = handler ?? _defaultExecutHandler; let _interruptHandler: ((this: vscode.NotebookController, notebook: vscode.NotebookDocument) => void | Thenable) | undefined; let _variableProvider: vscode.NotebookVariableProvider | undefined; + let _variableProviderDisposable: IDisposable | undefined; this._proxy.$addKernel(handle, data).catch(err => { // this can happen when a kernel with that ID is already registered @@ -234,9 +235,10 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { }, set variableProvider(value) { checkProposedApiEnabled(extension, 'notebookVariableProvider'); + _variableProviderDisposable?.dispose(); _variableProvider = value; data.hasVariableProvider = !!value; - value?.onDidChangeVariables(e => that._proxy.$variablesUpdated(e.uri)); + _variableProviderDisposable = value?.onDidChangeVariables(e => that._proxy.$variablesUpdated(e.uri)); _update(); }, get variableProvider() { @@ -270,6 +272,7 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { this._kernelData.delete(handle); onDidChangeSelection.dispose(); onDidReceiveMessage.dispose(); + _variableProviderDisposable?.dispose(); this._proxy.$removeKernel(handle); } }, diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 784c9edfadc..c7679ab51cb 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -22,8 +22,8 @@ import { getMenuBarVisibility, IPath, hasNativeTitlebar, hasCustomTitlebar, Titl import { IHostService } from '../services/host/browser/host.js'; import { IBrowserWorkbenchEnvironmentService } from '../services/environment/browser/environmentService.js'; import { IEditorService } from '../services/editor/common/editorService.js'; -import { EditorGroupLayout, GroupOrientation, GroupsOrder, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; -import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing } from '../../base/browser/ui/grid/grid.js'; +import { EditorGroupLayout, GroupActivationReason, GroupOrientation, GroupsOrder, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; +import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing, IViewVisibilityAnimationOptions } from '../../base/browser/ui/grid/grid.js'; import { Part } from './part.js'; import { IStatusbarService } from '../services/statusbar/browser/statusbar.js'; import { IFileService } from '../../platform/files/common/files.js'; @@ -47,6 +47,8 @@ import { AuxiliaryBarPart } from './parts/auxiliarybar/auxiliaryBarPart.js'; import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; import { IAuxiliaryWindowService } from '../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { CodeWindow, mainWindow } from '../../base/browser/window.js'; +import { EASE_OUT, EASE_IN } from '../../base/browser/ui/motion/motion.js'; +import { CancellationToken } from '../../base/common/cancellation.js'; //#region Layout Implementation @@ -380,7 +382,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi showEditorIfHidden(); } })); - this._register(this.editorGroupService.mainPart.onDidActivateGroup(showEditorIfHidden)); + this._register(this.editorGroupService.mainPart.onDidActivateGroup(e => { + if (e.reason !== GroupActivationReason.PART_CLOSE) { + showEditorIfHidden(); // only show unless a modal/auxiliary part closes + } + })); // Revalidate center layout when active editor changes: diff editor quits centered mode this._register(this.mainPartEditorService.onDidActiveEditorChange(() => this.centerMainEditorLayout(this.stateModel.getRuntimeValue(LayoutStateKeys.MAIN_EDITOR_CENTERED)))); @@ -1864,27 +1870,32 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.stateModel.setRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN, hidden); - // Adjust CSS - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.SIDEBAR_HIDDEN); - } else { + // Adjust CSS - for hiding, defer adding the class until animation + // completes so the part stays visible during the exit animation. + if (!hidden) { this.mainContainer.classList.remove(LayoutClasses.SIDEBAR_HIDDEN); } // Propagate to grid - this.workbenchGrid.setViewVisible(this.sideBarPartView, !hidden); + this.workbenchGrid.setViewVisible( + this.sideBarPartView, + !hidden, + createViewVisibilityAnimation(hidden, () => { + if (!hidden) { return; } + // Deferred to after close animation + this.mainContainer.classList.add(LayoutClasses.SIDEBAR_HIDDEN); + if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); - // If sidebar becomes hidden, also hide the current active Viewlet if any - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); - - if (!this.isAuxiliaryBarMaximized()) { - this.focusPanelOrEditor(); // do not auto focus when auxiliary bar is maximized - } - } + if (!this.isAuxiliaryBarMaximized()) { + this.focusPanelOrEditor(); // do not auto focus when auxiliary bar is maximized + } + } + }) + ); // If sidebar becomes visible, show last active Viewlet or default viewlet - else if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar); if (viewletToOpen) { this.openViewContainer(ViewContainerLocation.Sidebar, viewletToOpen); @@ -2008,13 +2019,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi const panelOpensMaximized = this.panelOpensMaximized(); - // Adjust CSS - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.PANEL_HIDDEN); - } else { - this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); - } - // If maximized and in process of hiding, unmaximize FIRST before // changing visibility to prevent conflict with setEditorHidden // which would force panel visible again (fixes #281772) @@ -2022,13 +2026,30 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.toggleMaximizedPanel(); } - // Propagate layout changes to grid - this.workbenchGrid.setViewVisible(this.panelPartView, !hidden); + // Adjust CSS - for hiding, defer adding the class until animation + // completes because `.nopanel .part.panel { display: none !important }` + // would instantly hide the panel content mid-animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); + } - // If panel part becomes hidden, also hide the current active panel if any + // Propagate layout changes to grid + this.workbenchGrid.setViewVisible( + this.panelPartView, + !hidden, + createViewVisibilityAnimation(hidden, () => { + if (!hidden) { return; } + // Deferred to after close animation + this.mainContainer.classList.add(LayoutClasses.PANEL_HIDDEN); + if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); + } + }) + ); + + // If panel part becomes hidden, focus the editor after animation starts let focusEditor = false; if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); if ( !isIOS && // do not auto focus on iOS (https://github.com/microsoft/vscode/issues/127832) !this.isAuxiliaryBarMaximized() // do not auto focus when auxiliary bar is maximized @@ -2202,24 +2223,30 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN, hidden); - // Adjust CSS - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.AUXILIARYBAR_HIDDEN); - } else { + // Adjust CSS - for hiding, defer adding the class until animation + // completes because `.noauxiliarybar .part.auxiliarybar { display: none !important }` + // would instantly hide the content mid-animation. + if (!hidden) { this.mainContainer.classList.remove(LayoutClasses.AUXILIARYBAR_HIDDEN); } // Propagate to grid - this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, !hidden); - - // If auxiliary bar becomes hidden, also hide the current active pane composite if any - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); - this.focusPanelOrEditor(); - } + this.workbenchGrid.setViewVisible( + this.auxiliaryBarPartView, + !hidden, + createViewVisibilityAnimation(hidden, () => { + if (!hidden) { return; } + // Deferred to after close animation + this.mainContainer.classList.add(LayoutClasses.AUXILIARYBAR_HIDDEN); + if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); + this.focusPanelOrEditor(); + } + }) + ); // If auxiliary bar becomes visible, show last active pane composite or default pane composite - else if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { let viewletToOpen: string | undefined = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar); // verify that the viewlet we try to open has views before we default to it @@ -2712,6 +2739,21 @@ function getZenModeConfiguration(configurationService: IConfigurationService): Z return configurationService.getValue(WorkbenchLayoutSettings.ZEN_MODE_CONFIG); } +/** Duration (ms) for panel/sidebar open (entrance) animations. */ +const PANEL_OPEN_DURATION = 135; + +/** Duration (ms) for panel/sidebar close (exit) animations. */ +const PANEL_CLOSE_DURATION = 35; + +function createViewVisibilityAnimation(hidden: boolean, onComplete?: () => void, token: CancellationToken = CancellationToken.None): IViewVisibilityAnimationOptions { + return { + duration: hidden ? PANEL_CLOSE_DURATION : PANEL_OPEN_DURATION, + easing: hidden ? EASE_IN : EASE_OUT, + token, + onComplete, + }; +} + //#endregion //#region Layout State Model diff --git a/src/vs/workbench/browser/media/motion.css b/src/vs/workbench/browser/media/motion.css new file mode 100644 index 00000000000..fbd8215265a --- /dev/null +++ b/src/vs/workbench/browser/media/motion.css @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Motion custom properties -- only active when motion is enabled */ +.monaco-workbench.monaco-enable-motion { + --vscode-motion-panel-open-duration: 175ms; + --vscode-motion-panel-close-duration: 75ms; + --vscode-motion-quick-input-open-duration: 175ms; + --vscode-motion-quick-input-close-duration: 75ms; + --vscode-motion-ease-out: cubic-bezier(0.1, 0.9, 0.2, 1); + --vscode-motion-ease-in: cubic-bezier(0.9, 0.1, 1, 0.2); +} + +/* Disable all motion durations when reduced motion is active */ +.monaco-workbench.monaco-reduce-motion { + --vscode-motion-panel-open-duration: 0ms; + --vscode-motion-panel-close-duration: 0ms; + --vscode-motion-quick-input-open-duration: 0ms; + --vscode-motion-quick-input-close-duration: 0ms; +} diff --git a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index ec1cf50a7be..327b8bd85dc 100644 --- a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts @@ -5,6 +5,7 @@ import { onDidChangeFullscreen } from '../../../../base/browser/browser.js'; import { $, getActiveWindow, hide, show } from '../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../base/browser/window.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, markAsSingleton, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { isNative } from '../../../../base/common/platform.js'; @@ -20,10 +21,10 @@ import { EditorPart, IEditorPartUIState } from './editorPart.js'; import { IAuxiliaryTitlebarPart } from '../titlebar/titlebarPart.js'; import { WindowTitle } from '../titlebar/windowTitle.js'; import { IAuxiliaryWindowOpenOptions, IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; -import { GroupDirection, GroupsOrder, IAuxiliaryEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { GroupDirection, GroupsOrder, IAuxiliaryEditorPart, GroupActivationReason } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IHostService } from '../../../services/host/browser/host.js'; -import { IWorkbenchLayoutService, shouldShowCustomTitleBar } from '../../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts, shouldShowCustomTitleBar } from '../../../services/layout/browser/layoutService.js'; import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; import { IStatusbarService } from '../../../services/statusbar/browser/statusbar.js'; import { ITitleService } from '../../../services/title/browser/titleService.js'; @@ -409,13 +410,19 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart private doRemoveLastGroup(preserveFocus?: boolean): void { const restoreFocus = !preserveFocus && this.shouldRestoreFocus(this.container); - // Activate next group + // Activate next group when closing const mostRecentlyActiveGroups = this.editorPartsView.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); const nextActiveGroup = mostRecentlyActiveGroups[1]; // [0] will be the current group we are about to dispose if (nextActiveGroup) { - nextActiveGroup.groupsView.activateGroup(nextActiveGroup); + nextActiveGroup.groupsView.activateGroup(nextActiveGroup, undefined, GroupActivationReason.PART_CLOSE); + } - if (restoreFocus) { + // Deal with focus: focus the next recently used group but skip + // this if the next group is in the main part and the main part + // is currently hidden, as that would make it visible. + if (nextActiveGroup && restoreFocus) { + const nextGroupInHiddenMainPart = nextActiveGroup.groupsView === this.editorPartsView.mainPart && !this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow); + if (!nextGroupInHiddenMainPart) { nextActiveGroup.focus(); } } diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index 96779124403..9a1b9ab092f 100644 --- a/src/vs/workbench/browser/parts/editor/editor.ts +++ b/src/vs/workbench/browser/parts/editor/editor.ts @@ -5,7 +5,7 @@ import { GroupIdentifier, IWorkbenchEditorConfiguration, IEditorIdentifier, IEditorCloseEvent, IEditorPartOptions, IEditorPartOptionsChangeEvent, SideBySideEditor, EditorCloseContext, IEditorPane, IEditorPartLimitOptions, IEditorPartDecorationOptions, IEditorWillOpenEvent, EditorInputWithOptions } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorGroup, GroupDirection, IMergeGroupOptions, GroupsOrder, GroupsArrangement, IAuxiliaryEditorPart, IEditorPart, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, GroupDirection, IMergeGroupOptions, GroupsOrder, GroupsArrangement, IAuxiliaryEditorPart, IEditorPart, IModalEditorPart, GroupActivationReason } from '../../../services/editor/common/editorGroupsService.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Dimension } from '../../../../base/browser/dom.js'; import { Event } from '../../../../base/common/event.js'; @@ -218,7 +218,7 @@ export interface IEditorGroupsView { getGroup(identifier: GroupIdentifier): IEditorGroupView | undefined; getGroups(order: GroupsOrder): IEditorGroupView[]; - activateGroup(identifier: IEditorGroupView | GroupIdentifier, preserveWindowOrder?: boolean): IEditorGroupView; + activateGroup(identifier: IEditorGroupView | GroupIdentifier, preserveWindowOrder?: boolean, reason?: GroupActivationReason): IEditorGroupView; restoreGroup(identifier: IEditorGroupView | GroupIdentifier): IEditorGroupView; addGroup(location: IEditorGroupView | GroupIdentifier, direction: GroupDirection, groupToCopy?: IEditorGroupView): IEditorGroupView; diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 56ca88b06d6..4326662a9ab 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -8,7 +8,7 @@ import { Part } from '../../part.js'; import { Dimension, $, EventHelper, addDisposableGenericMouseDownListener, getWindow, isAncestorOfActiveElement, getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js'; import { Event, Emitter, Relay, PauseableEmitter } from '../../../../base/common/event.js'; import { contrastBorder, editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; -import { GroupDirection, GroupsArrangement, GroupOrientation, IMergeGroupOptions, MergeGroupMode, GroupsOrder, GroupLocation, IFindGroupScope, EditorGroupLayout, GroupLayoutArgument, IEditorSideGroup, IEditorDropTargetDelegate, IEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { GroupDirection, GroupsArrangement, GroupOrientation, IMergeGroupOptions, MergeGroupMode, GroupsOrder, GroupLocation, IFindGroupScope, EditorGroupLayout, GroupLayoutArgument, IEditorSideGroup, IEditorDropTargetDelegate, IEditorPart, GroupActivationReason, IEditorGroupActivationEvent } from '../../../services/editor/common/editorGroupsService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IView, orthogonal, LayoutPriority, IViewSize, Direction, SerializableGrid, Sizing, ISerializedGrid, ISerializedNode, Orientation, GridBranchNode, isGridBranchNode, GridNode, createSerializedGrid, Grid } from '../../../../base/browser/ui/grid/grid.js'; import { GroupIdentifier, EditorInputWithOptions, IEditorPartOptions, IEditorPartOptionsChangeEvent, GroupModelChangeKind } from '../../../common/editor.js'; @@ -116,7 +116,7 @@ export class EditorPart extends Part implements IEditorPart, private readonly _onDidChangeGroupMaximized = this._register(new Emitter()); readonly onDidChangeGroupMaximized = this._onDidChangeGroupMaximized.event; - private readonly _onDidActivateGroup = this._register(new Emitter()); + private readonly _onDidActivateGroup = this._register(new Emitter()); readonly onDidActivateGroup = this._onDidActivateGroup.event; private readonly _onDidAddGroup = this._register(new PauseableEmitter()); @@ -365,9 +365,9 @@ export class EditorPart extends Part implements IEditorPart, } } - activateGroup(group: IEditorGroupView | GroupIdentifier, preserveWindowOrder?: boolean): IEditorGroupView { + activateGroup(group: IEditorGroupView | GroupIdentifier, preserveWindowOrder?: boolean, reason?: GroupActivationReason): IEditorGroupView { const groupView = this.assertGroupView(group); - this.doSetGroupActive(groupView); + this.doSetGroupActive(groupView, reason); // Ensure window on top unless disabled if (!preserveWindowOrder) { @@ -684,7 +684,7 @@ export class EditorPart extends Part implements IEditorPart, return groupView; } - private doSetGroupActive(group: IEditorGroupView): void { + private doSetGroupActive(group: IEditorGroupView, reason = GroupActivationReason.DEFAULT): void { if (this._activeGroup !== group) { const previousActiveGroup = this._activeGroup; this._activeGroup = group; @@ -710,7 +710,7 @@ export class EditorPart extends Part implements IEditorPart, // Always fire the event that a group has been activated // even if its the same group that is already active to // signal the intent even when nothing has changed. - this._onDidActivateGroup.fire(group); + this._onDidActivateGroup.fire({ group, reason }); } private doRestoreGroup(group: IEditorGroupView): void { diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 61703e493fd..85651989603 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; -import { EditorGroupLayout, GroupDirection, GroupLocation, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, IEditorGroupContextKeyProvider, IEditorDropTargetDelegate, IEditorGroupsService, IEditorSideGroup, IEditorWorkingSet, IFindGroupScope, IMergeGroupOptions, IEditorWorkingSetOptions, IEditorPart, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { EditorGroupLayout, GroupDirection, GroupLocation, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, IEditorGroupContextKeyProvider, IEditorDropTargetDelegate, IEditorGroupsService, IEditorSideGroup, IEditorWorkingSet, IFindGroupScope, IMergeGroupOptions, IEditorWorkingSetOptions, IEditorPart, IModalEditorPart, IEditorGroupActivationEvent } from '../../../services/editor/common/editorGroupsService.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { GroupIdentifier, IEditorPartOptions } from '../../../common/editor.js'; @@ -234,7 +234,7 @@ export class EditorParts extends MultiWindowParts this._onDidAddGroup.fire(group))); disposables.add(part.onDidRemoveGroup(group => this._onDidRemoveGroup.fire(group))); disposables.add(part.onDidMoveGroup(group => this._onDidMoveGroup.fire(group))); - disposables.add(part.onDidActivateGroup(group => this._onDidActivateGroup.fire(group))); + disposables.add(part.onDidActivateGroup(e => this._onDidActivateGroup.fire(e))); disposables.add(part.onDidChangeGroupMaximized(maximized => this._onDidChangeGroupMaximized.fire(maximized))); disposables.add(part.onDidChangeGroupIndex(group => this._onDidChangeGroupIndex.fire(group))); @@ -558,7 +558,7 @@ export class EditorParts extends MultiWindowParts()); readonly onDidMoveGroup = this._onDidMoveGroup.event; - private readonly _onDidActivateGroup = this._register(new Emitter()); + private readonly _onDidActivateGroup = this._register(new Emitter()); readonly onDidActivateGroup = this._onDidActivateGroup.event; private readonly _onDidChangeGroupIndex = this._register(new Emitter()); diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 6e65299f400..8d2c6acb592 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/modalEditorPart.css'; -import { $, addDisposableListener, append, EventHelper, EventType } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventHelper, EventType, isHTMLElement } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -20,12 +20,12 @@ import { IStorageService } from '../../../../platform/storage/common/storage.js' import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IEditorGroupView, IEditorPartsView } from './editor.js'; import { EditorPart } from './editorPart.js'; -import { GroupDirection, GroupsOrder, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { GroupDirection, GroupsOrder, IModalEditorPart, GroupActivationReason } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPartModalContext, EditorPartModalMaximizedContext } from '../../../common/contextkeys.js'; import { Verbosity } from '../../../common/editor.js'; import { IHostService } from '../../../services/host/browser/host.js'; -import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { localize } from '../../../../nls.js'; @@ -208,6 +208,8 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly optionsDisposable = this._register(new MutableDisposable()); + private previousMainWindowActiveElement: Element | null = null; + constructor( windowId: number, editorPartsView: IEditorPartsView, @@ -226,6 +228,12 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { this.enforceModalPartOptions(); } + override create(parent: HTMLElement, options?: object): void { + this.previousMainWindowActiveElement = mainWindow.document.activeElement; + + super.create(parent, options); + } + private enforceModalPartOptions(): void { const editorCount = this.groups.reduce((count, group) => count + group.count, 0); this.optionsDisposable.value = this.enforcePartOptions({ @@ -264,7 +272,7 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { // Close modal when last group removed const groupView = this.assertGroupView(group); if (this.count === 1 && this.activeGroup === groupView) { - this.doRemoveLastGroup(preserveFocus); + this.doRemoveLastGroup(); } // Otherwise delegate to parent implementation @@ -273,18 +281,26 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { } } - private doRemoveLastGroup(preserveFocus?: boolean): void { - const restoreFocus = !preserveFocus && this.shouldRestoreFocus(this.container); + private doRemoveLastGroup(): void { - // Activate next group - const mostRecentlyActiveGroups = this.editorPartsView.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); - const nextActiveGroup = mostRecentlyActiveGroups[1]; // [0] will be the current group we are about to dispose - if (nextActiveGroup) { - nextActiveGroup.groupsView.activateGroup(nextActiveGroup); + // Activate main editor group when closing + const activeMainGroup = this.editorPartsView.mainPart.activeGroup; + this.editorPartsView.mainPart.activateGroup(activeMainGroup, undefined, GroupActivationReason.PART_CLOSE); - if (restoreFocus) { - nextActiveGroup.focus(); - } + // Deal with focus: removing the last modal group + // means we return back to the main editor part. + // But we only want to focus that if it was focused + // before to prevent revealing the editor part if + // it was maybe hidden before. + const mainEditorPartContainer = this.layoutService.getContainer(mainWindow, Parts.EDITOR_PART); + if ( + !isHTMLElement(this.previousMainWindowActiveElement) || // invalid previous element + !this.previousMainWindowActiveElement.isConnected || // previous element no longer in the DOM + mainEditorPartContainer?.contains(this.previousMainWindowActiveElement) // previous element is inside main editor part + ) { + activeMainGroup.focus(); + } else { + this.previousMainWindowActiveElement.focus(); } this.doClose({ mergeConfirmingEditorsToMainPart: false }); diff --git a/src/vs/workbench/browser/style.ts b/src/vs/workbench/browser/style.ts index 9250ef3f280..de344e7e46b 100644 --- a/src/vs/workbench/browser/style.ts +++ b/src/vs/workbench/browser/style.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/style.css'; +import './media/motion.css'; import { registerThemingParticipant } from '../../platform/theme/common/themeService.js'; import { WORKBENCH_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND } from '../common/theme.js'; import { isWeb, isIOS } from '../../base/common/platform.js'; diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 1f88332b739..0cd00519f0e 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -39,8 +39,17 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation } const verifiedWidget: IChatWidget = widget; - const focusedItem = verifiedWidget.getFocus(); - if (!focusedItem) { + let focusedItem = verifiedWidget.getFocus(); + if (!focusedItem || !isResponseVM(focusedItem)) { + const responseItems = verifiedWidget.viewModel?.getItems().filter(isResponseVM); + const lastResponse = responseItems?.at(-1); + if (lastResponse) { + focusedItem = lastResponse; + verifiedWidget.focus(lastResponse); + } + } + + if (!focusedItem || !isResponseVM(focusedItem)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 4a6166dceea..3c23b729b2a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -88,6 +88,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age content.push(localize('chat.focusMostRecentTerminal', 'To focus the last chat terminal that ran a tool, invoke the Focus Most Recent Chat Terminal command{0}.', ``)); content.push(localize('chat.focusMostRecentTerminalOutput', 'To focus the output from the last chat terminal tool, invoke the Focus Most Recent Chat Terminal Output command{0}.', ``)); content.push(localize('chat.focusQuestionCarousel', 'When a chat question appears, toggle focus between the question and the chat input{0}.', '')); + content.push(localize('chat.focusTip', 'When a tip appears, toggle focus between the tip and the chat input{0}.', '')); } if (type === 'editsView' || type === 'agentView') { if (type === 'agentView') { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 980ba420f12..158ece0a481 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -51,7 +51,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatModel, IChatResponseModel } from '../../common/model/chatModel.js'; import { ChatMode, IChatMode, IChatModeService } from '../../common/chatModes.js'; -import { IChatService } from '../../common/chatService/chatService.js'; +import { ElicitationState, IChatService, IChatToolInvocation } from '../../common/chatService/chatService.js'; import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; @@ -212,6 +212,7 @@ abstract class OpenChatGlobalAction extends Action2 { const languageModelService = accessor.get(ILanguageModelsService); const scmService = accessor.get(ISCMService); const logService = accessor.get(ILogService); + const configurationService = accessor.get(IConfigurationService); let chatWidget = widgetService.lastFocusedWidget; // When this was invoked to switch to a mode via keybinding, and some chat widget is focused, use that one. @@ -388,16 +389,38 @@ abstract class OpenChatGlobalAction extends Action2 { if (opts?.blockOnResponse) { const response = await resp; if (response) { + const autoReplyEnabled = configurationService.getValue(ChatConfiguration.AutoReply); await new Promise(resolve => { const d = response.onDidChange(async () => { - if (response.isComplete || response.isPendingConfirmation.get()) { + if (response.isComplete) { + d.dispose(); + resolve(); + return; + } + + const pendingConfirmation = response.isPendingConfirmation.get(); + if (pendingConfirmation) { + // Check if the pending confirmation is a question carousel that will be auto-replied. + // Only question carousels are auto-replied; other confirmation types (tool approvals, + // elicitations, etc.) should cause us to resolve immediately. + const hasPendingQuestionCarousel = response.response.value.some( + part => part.kind === 'questionCarousel' && !part.isUsed + ); + if (autoReplyEnabled && hasPendingQuestionCarousel) { + // Auto-reply will handle this question carousel, keep waiting + return; + } d.dispose(); resolve(); } }); }); - return { ...response.result, type: response.isPendingConfirmation.get() ? 'confirmation' : undefined }; + const confirmationInfo = getPendingConfirmationInfo(response); + if (confirmationInfo) { + return { ...response.result, ...confirmationInfo }; + } + return { ...response.result }; } } @@ -437,6 +460,66 @@ async function waitForDefaultAgent(chatAgentService: IChatAgentService, mode: Ch ]); } +/** + * Information about a pending confirmation in a chat response. + */ +export type IChatPendingConfirmationInfo = + | { type: 'confirmation'; kind: 'toolInvocation'; toolId: string } + | { type: 'confirmation'; kind: 'toolPostApproval'; toolId: string } + | { type: 'confirmation'; kind: 'confirmation'; title: string; data: unknown } + | { type: 'confirmation'; kind: 'questionCarousel'; questions: unknown[] } + | { type: 'confirmation'; kind: 'elicitation'; title: string }; + +/** + * Extracts detailed information about the pending confirmation from a chat response. + * Returns undefined if there is no pending confirmation. + */ +function getPendingConfirmationInfo(response: IChatResponseModel): IChatPendingConfirmationInfo | undefined { + for (const part of response.response.value) { + if (part.kind === 'toolInvocation') { + const state = part.state.get(); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { + return { + type: 'confirmation', + kind: 'toolInvocation', + toolId: part.toolId, + }; + } + if (state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { + return { + type: 'confirmation', + kind: 'toolPostApproval', + toolId: part.toolId, + }; + } + } + if (part.kind === 'confirmation' && !part.isUsed) { + return { + type: 'confirmation', + kind: 'confirmation', + title: part.title, + data: part.data, + }; + } + if (part.kind === 'questionCarousel' && !part.isUsed) { + return { + type: 'confirmation', + kind: 'questionCarousel', + questions: part.questions, + }; + } + if (part.kind === 'elicitation2' && part.state.get() === ElicitationState.Pending) { + const title = part.title; + return { + type: 'confirmation', + kind: 'elicitation', + title: typeof title === 'string' ? title : title.value, + }; + } + } + return undefined; +} + class PrimaryOpenChatGlobalAction extends OpenChatGlobalAction { constructor() { super({ @@ -809,6 +892,37 @@ export function registerChatActions() { } }); + registerAction2(class FocusTipAction extends Action2 { + static readonly ID = 'workbench.action.chat.focusTip'; + + constructor() { + super({ + id: FocusTipAction.ID, + title: localize2('interactiveSession.focusTip.label', "Chat: Toggle Focus Between Tip and Input"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.inChatSession, + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Slash, + when: ContextKeyExpr.or( + ChatContextKeys.inChatSession, + ChatContextKeys.inChatTip + ), + }] + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + + if (!widget || !widget.toggleTipFocus()) { + alert(localize('chat.tip.focusUnavailable', "No chat tip.")); + } + } + }); + registerAction2(class ShowContextUsageAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index d1938adf33f..2192b382727 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -29,6 +29,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, } from '../../commo import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { isInClaudeAgentsFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { getAgentSessionProvider, AgentSessionProviders } from '../agentSessions/agentSessions.js'; @@ -260,6 +261,7 @@ type ChatModeChangeClassification = { extensionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Extension ID if the target mode is from an extension' }; toolsCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of custom tools in the target mode'; 'isMeasurement': true }; handoffsCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of handoffs in the target mode'; 'isMeasurement': true }; + isClaudeAgent?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the target mode is a Claude agent file from .claude/agents/' }; }; type ChatModeChangeEvent = { @@ -270,6 +272,7 @@ type ChatModeChangeEvent = { extensionId?: string; toolsCount?: number; handoffsCount?: number; + isClaudeAgent?: boolean; }; class ToggleChatModeAction extends Action2 { @@ -337,6 +340,9 @@ class ToggleChatModeAction extends Action2 { return mode.name.get(); }; + const modeUri = switchToMode.uri?.get(); + const isClaudeAgent = modeUri ? isInClaudeAgentsFolder(modeUri) : undefined; + telemetryService.publicLog2('chat.modeChange', { fromMode: getModeNameForTelemetry(currentMode), mode: getModeNameForTelemetry(switchToMode), @@ -344,7 +350,8 @@ class ToggleChatModeAction extends Action2 { storage, extensionId, toolsCount, - handoffsCount + handoffsCount, + isClaudeAgent }); widget.input.setChatMode(switchToMode.id); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index 56b2429570c..a442be07b03 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -25,9 +25,7 @@ import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatService } from '. import { isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatModeKind } from '../../common/constants.js'; import { IChatAccessibilityService, IChatWidgetService } from '../chat.js'; -import { triggerConfetti } from '../widget/chatConfetti.js'; import { CHAT_CATEGORY } from './chatActions.js'; -import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; export const MarkUnhelpfulActionId = 'workbench.action.chat.markUnhelpful'; const enableFeedbackConfig = 'config.telemetry.feedback.enabled'; @@ -77,16 +75,6 @@ export function registerChatTitleActions() { }); item.setVote(ChatAgentVoteDirection.Up); item.setVoteDownReason(undefined); - - const configurationService = accessor.get(IConfigurationService); - const accessibilityService = accessor.get(IAccessibilityService); - if (configurationService.getValue('chat.confettiOnThumbsUp') && !accessibilityService.isMotionReduced()) { - const chatWidgetService = accessor.get(IChatWidgetService); - const widget = chatWidgetService.getWidgetBySessionResource(item.session.sessionResource); - if (widget) { - triggerConfetti(widget.domNode); - } - } } }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 2bd4108b727..5db250de5b3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -9,7 +9,7 @@ height: 100%; min-height: 0; - .monaco-scrollable-element { + .pane-body & .monaco-scrollable-element { padding: 0 6px; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f51de3f44f1..ff1604d72b9 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -287,11 +287,7 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, - 'chat.confettiOnThumbsUp': { - type: 'boolean', - description: nls.localize('chat.confettiOnThumbsUp', "Controls whether a confetti animation is shown when clicking the thumbs up button on a chat response."), - default: false, - }, + 'chat.experimental.detectParticipant.enabled': { type: 'boolean', deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), @@ -327,6 +323,13 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.notifyWindowOnConfirmation', "Controls whether a chat session should present the user with an OS notification when a confirmation is needed while the window is not in focus. This includes a window badge as well as notification toast."), default: true, }, + [ChatConfiguration.AutoReply]: { + default: false, + markdownDescription: nls.localize('chat.autoReply.description', "Automatically answer chat question carousels using the current model. This is an advanced setting and can lead to unintended choices or actions based on incomplete context."), + type: 'boolean', + scope: ConfigurationScope.APPLICATION_MACHINE, + tags: ['experimental', 'advanced'], + }, [ChatConfiguration.GlobalAutoApprove]: { default: false, markdownDescription: globalAutoApproveDescription.value, @@ -962,7 +965,7 @@ configurationRegistry.registerConfiguration({ patternErrorMessage: nls.localize('chat.hookFilesLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), }, restricted: true, - tags: ['prompts', 'hooks', 'agent'], + tags: ['preview', 'prompts', 'hooks', 'agent'], examples: [ { [DEFAULT_HOOK_FILE_PATHS[0].path]: true, @@ -975,12 +978,24 @@ configurationRegistry.registerConfiguration({ }, [PromptsConfig.USE_CHAT_HOOKS]: { type: 'boolean', - title: nls.localize('chat.useChatHooks.title', "Use Chat Hooks",), - markdownDescription: nls.localize('chat.useChatHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), + title: nls.localize('chat.useHooks.title', "Use Chat Hooks",), + markdownDescription: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), default: true, restricted: true, disallowConfigurationDefault: true, - tags: ['prompts', 'hooks', 'agent'] + tags: ['preview', 'prompts', 'hooks', 'agent'], + policy: { + name: 'ChatHooks', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.109', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'chat.useHooks.description', + value: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",) + } + }, + } }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 2d2b307f23e..fc4423e96d1 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -386,6 +386,12 @@ export interface IChatWidget { * @returns Whether the operation succeeded (i.e., the focus was toggled). */ toggleQuestionCarouselFocus(): boolean; + /** + * Toggles focus between the tip widget and the chat input. + * Returns false if no tip is visible. + * @returns Whether the operation succeeded (i.e., the focus was toggled). + */ + toggleTipFocus(): boolean; hasInputFocus(): boolean; getModeRequestOptions(): Partial; getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 5c55c8159ad..bea60daa877 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -26,6 +26,7 @@ export const IChatTipService = createDecorator('chatTipService' export interface IChatTip { readonly id: string; readonly content: MarkdownString; + readonly enabledCommands?: readonly string[]; } export interface IChatTipService { @@ -43,17 +44,31 @@ export interface IChatTipService { /** * Gets a tip to show for a request, or undefined if a tip has already been shown this session. - * Only one tip is shown per VS Code session (resets on reload). - * Tips are only shown for requests created after the service was instantiated. + * Only one tip is shown per conversation session (resets when switching conversations). + * Tips are suppressed if a welcome tip was already shown in this session. + * Tips are only shown for requests created after the current session started. * @param requestId The unique ID of the request (used for stable rerenders). * @param requestTimestamp The timestamp when the request was created. * @param contextKeyService The context key service to evaluate tip eligibility. */ getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined; + /** + * Gets a tip to show on the welcome/getting-started view. + * Unlike {@link getNextTip}, this does not require a request and skips request-timestamp checks. + * Returns the same tip on repeated calls for stable rerenders. + */ + getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Resets tip state for a new conversation. + * Call this when the chat widget binds to a new model. + */ + resetSession(): void; + /** * Dismisses the current tip and allows a new one to be picked for the same request. - * The dismissed tip will not be shown again in this workspace. + * The dismissed tip will not be shown again in this profile. */ dismissTip(): void; @@ -91,6 +106,11 @@ export interface ITipDefinition { * The tip won't be shown if the tool it describes has already been used. */ readonly excludeWhenToolsInvoked?: string[]; + /** + * Tool set reference names where at least one must be registered for the tip to be eligible. + * If none of the listed tool sets are registered, the tip is not shown. + */ + readonly requiresAnyToolSetRegistered?: string[]; /** * If set, exclude this tip when prompt files of the specified type exist in the workspace. */ @@ -133,7 +153,7 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.undoChanges', - message: localize('tip.undoChanges', "Tip: You can undo Copilot's changes to any point by clicking Restore Checkpoint."), + message: localize('tip.undoChanges', "Tip: You can undo chat's changes to any point by clicking Restore Checkpoint."), when: ContextKeyExpr.or( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Edit), @@ -142,7 +162,7 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.customInstructions', - message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) so Copilot always has the context it needs when starting a task."), + message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) so chat always has the context it needs when starting a task."), enabledCommands: ['workbench.action.chat.generateInstructions'], excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }, @@ -164,13 +184,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.messageQueueing', - message: localize('tip.messageQueueing', "Tip: You can send follow-up messages and steering while the agent is working. They'll be queued and processed in order."), + message: localize('tip.messageQueueing', "Tip: You can send follow-up and steering messages while the agent is working. They'll be queued and processed in order."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], }, { id: 'tip.yoloMode', - message: localize('tip.yoloMode', "Tip: Enable [auto approval](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), + message: localize('tip.yoloMode', "Tip: Enable [auto approve](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), @@ -191,6 +211,7 @@ const TIP_CATALOG: ITipDefinition[] = [ ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), ), excludeWhenToolsInvoked: ['github-pull-request_doSearch', 'github-pull-request_issue_fetch', 'github-pull-request_formSearchQuery'], + requiresAnyToolSetRegistered: ['github', 'github-pull-request'], }, { id: 'tip.subagents', @@ -257,7 +278,7 @@ export class TipEligibilityTracker extends Disposable { @ICommandService commandService: ICommandService, @IStorageService private readonly _storageService: IStorageService, @IPromptsService private readonly _promptsService: IPromptsService, - @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, @ILogService private readonly _logService: ILogService, ) { super(); @@ -321,7 +342,7 @@ export class TipEligibilityTracker extends Disposable { // --- Set up tool listener (auto-disposes when all seen) ----------------- if (this._pendingTools.size > 0) { - this._toolListener.value = languageModelToolsService.onDidInvokeTool(e => { + this._toolListener.value = this._languageModelToolsService.onDidInvokeTool(e => { if (this._pendingTools.has(e.toolId)) { this._invokedTools.add(e.toolId); this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); @@ -414,6 +435,13 @@ export class TipEligibilityTracker extends Disposable { this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); return true; } + if (tip.requiresAnyToolSetRegistered) { + const hasAny = tip.requiresAnyToolSetRegistered.some(name => this._languageModelToolsService.getToolSetByName(name)); + if (!hasAny) { + this._logService.debug('#ChatTips: tip excluded because no required tool sets are registered', tip.id); + return true; + } + } return false; } @@ -468,16 +496,17 @@ export class ChatTipService extends Disposable implements IChatTipService { readonly onDidDisableTips = this._onDidDisableTips.event; /** - * Timestamp when this service was instantiated. + * Timestamp when the current session started. * Used to only show tips for requests created after this time. + * Resets on each {@link resetSession} call. */ - private readonly _createdAt = Date.now(); + private _sessionStartedAt = Date.now(); /** - * Whether a tip has already been shown in this window session. - * Only one tip is shown per session. + * Whether a chatResponse tip has already been shown in this conversation + * session. Only one response tip is shown per session. */ - private _hasShownTip = false; + private _hasShownRequestTip = false; /** * The request ID that was assigned a tip (for stable rerenders). @@ -490,6 +519,7 @@ export class ChatTipService extends Disposable implements IChatTipService { private _shownTip: ITipDefinition | undefined; private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; + private static readonly _LAST_TIP_ID_KEY = 'chat.tip.lastTipId'; private readonly _tracker: TipEligibilityTracker; constructor( @@ -503,34 +533,52 @@ export class ChatTipService extends Disposable implements IChatTipService { this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); } + resetSession(): void { + this._hasShownRequestTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._sessionStartedAt = Date.now(); + } + dismissTip(): void { if (this._shownTip) { const dismissed = this._getDismissedTipIds(); dismissed.push(this._shownTip.id); - this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.PROFILE, StorageTarget.MACHINE); } - this._hasShownTip = false; + this._hasShownRequestTip = false; this._shownTip = undefined; this._tipRequestId = undefined; this._onDidDismissTip.fire(); } private _getDismissedTipIds(): string[] { - const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.WORKSPACE); + const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); if (!raw) { return []; } try { const parsed = JSON.parse(raw); this._logService.debug('#ChatTips dismissed:', parsed); - return Array.isArray(parsed) ? parsed : []; + if (!Array.isArray(parsed)) { + return []; + } + + // Safety valve: if every known tip has been dismissed (for example, due to a + // past bug that dismissed the current tip on every new session), treat this + // as "no tips dismissed" so the feature can recover. + if (parsed.length >= TIP_CATALOG.length) { + return []; + } + + return parsed; } catch { return []; } } async disableTips(): Promise { - this._hasShownTip = false; + this._hasShownRequestTip = false; this._shownTip = undefined; this._tipRequestId = undefined; await this._configurationService.updateValue('chat.tips.enabled', false); @@ -553,34 +601,98 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._createTip(this._shownTip); } + // A new request arrived while we already showed a tip, hide the old one + if (this._hasShownRequestTip && this._tipRequestId && this._tipRequestId !== requestId) { + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidDismissTip.fire(); + return undefined; + } + // Only show one tip per session - if (this._hasShownTip) { + if (this._hasShownRequestTip) { return undefined; } - // Only show tips for requests created after the service was instantiated - // This prevents showing tips for old requests being re-rendered after reload - if (requestTimestamp < this._createdAt) { + // Only show tips for requests created after the current session started. + // This prevents showing tips for old requests being re-rendered. + if (requestTimestamp < this._sessionStartedAt) { return undefined; } - // Find eligible tips (excluding dismissed ones) - const dismissedIds = new Set(this._getDismissedTipIds()); - const eligibleTips = TIP_CATALOG.filter(tip => !dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)); - // Record the current mode for future eligibility decisions + return this._pickTip(requestId, contextKeyService); + } + + getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined { + // Check if tips are enabled + if (!this._configurationService.getValue('chat.tips.enabled')) { + return undefined; + } + + // Only show tips for Copilot + if (!this._isCopilotEnabled()) { + return undefined; + } + + // Return the already-shown tip for stable rerenders + if (this._tipRequestId === 'welcome' && this._shownTip) { + return this._createTip(this._shownTip); + } + + const tip = this._pickTip('welcome', contextKeyService); + + return tip; + } + + private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined { + // Record the current mode for future eligibility decisions. this._tracker.recordCurrentMode(contextKeyService); - if (eligibleTips.length === 0) { - return undefined; + const dismissedIds = new Set(this._getDismissedTipIds()); + let selectedTip: ITipDefinition | undefined; + + // Determine where to start in the catalog based on the last-shown tip. + const lastTipId = this._storageService.get(ChatTipService._LAST_TIP_ID_KEY, StorageScope.PROFILE); + const lastCatalogIndex = lastTipId ? TIP_CATALOG.findIndex(tip => tip.id === lastTipId) : -1; + const startIndex = lastCatalogIndex === -1 ? 0 : (lastCatalogIndex + 1) % TIP_CATALOG.length; + + // Pass 1: walk TIP_CATALOG in a ring, picking the first tip that is both + // not dismissed and eligible for the current context. + for (let i = 0; i < TIP_CATALOG.length; i++) { + const idx = (startIndex + i) % TIP_CATALOG.length; + const candidate = TIP_CATALOG[idx]; + if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { + selectedTip = candidate; + break; + } } - // Pick a random tip from eligible tips - const randomIndex = Math.floor(Math.random() * eligibleTips.length); - const selectedTip = eligibleTips[randomIndex]; + // Pass 2: if everything was ineligible (e.g., user has already done all + // the suggested actions), still advance through the catalog but only skip + // tips that were explicitly dismissed. + if (!selectedTip) { + for (let i = 0; i < TIP_CATALOG.length; i++) { + const idx = (startIndex + i) % TIP_CATALOG.length; + const candidate = TIP_CATALOG[idx]; + if (!dismissedIds.has(candidate.id)) { + selectedTip = candidate; + break; + } + } + } + + // Final fallback: if even that fails (all tips dismissed), stick with the + // catalog order so rotation still progresses. + if (!selectedTip) { + selectedTip = TIP_CATALOG[startIndex]; + } + + // Persist the selected tip id so the next use advances to the following one. + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.PROFILE, StorageTarget.USER); // Record that we've shown a tip this session - this._hasShownTip = true; - this._tipRequestId = requestId; + this._hasShownRequestTip = sourceId !== 'welcome'; + this._tipRequestId = sourceId; this._shownTip = selectedTip; return this._createTip(selectedTip); @@ -610,6 +722,7 @@ export class ChatTipService extends Disposable implements IChatTipService { return { id: tipDef.id, content: markdown, + enabledCommands: tipDef.enabledCommands, }; } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index babfb20a486..e37b11c22cd 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { parse as parseJSONC } from '../../../../../base/common/jsonc.js'; +import { setProperty, applyEdits } from '../../../../../base/common/jsonEdit.js'; +import { FormattingOptions } from '../../../../../base/common/jsonFormatter.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; @@ -102,7 +105,7 @@ async function addHookToFile( if (fileExists) { const existingContent = await fileService.readFile(hookFileUri); try { - hooksContent = JSON.parse(existingContent.value.toString()); + hooksContent = parseJSONC(existingContent.value.toString()); // Ensure hooks object exists if (!hooksContent.hooks) { hooksContent.hooks = {}; @@ -144,19 +147,25 @@ async function addHookToFile( // Use existing key if found, otherwise use the detected naming convention const keyToUse = existingKeyForType ?? hookTypeKeyName; - // Add the new hook entry (append if hook type already exists) + // Determine the new hook index (append if hook type already exists) const newHookEntry = buildNewHookEntry(sourceFormat); - let newHookIndex: number; - if (!hooksContent.hooks[keyToUse]) { - hooksContent.hooks[keyToUse] = [newHookEntry]; - newHookIndex = 0; - } else { - hooksContent.hooks[keyToUse].push(newHookEntry); - newHookIndex = hooksContent.hooks[keyToUse].length - 1; - } + const existingHooks = hooksContent.hooks[keyToUse]; + const newHookIndex = Array.isArray(existingHooks) ? existingHooks.length : 0; - // Write the file - const jsonContent = JSON.stringify(hooksContent, null, '\t'); + // Generate the new JSON content using setProperty to preserve comments + let jsonContent: string; + if (fileExists) { + // Use setProperty to make targeted edits that preserve comments + const originalText = (await fileService.readFile(hookFileUri)).value.toString(); + const detectedEol = originalText.includes('\r\n') ? '\r\n' : '\n'; + const formattingOptions: FormattingOptions = { tabSize: 1, insertSpaces: false, eol: detectedEol }; + const edits = setProperty(originalText, ['hooks', keyToUse, newHookIndex], newHookEntry, formattingOptions); + jsonContent = applyEdits(originalText, edits); + } else { + // New file - use JSON.stringify since there are no comments to preserve + const newContent = { hooks: { [keyToUse]: [newHookEntry] } }; + jsonContent = JSON.stringify(newContent, null, '\t'); + } // Check if the file is already open in an editor const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); @@ -611,7 +620,7 @@ export async function showConfigureHooksQuickPick( const inputBox = inputDisposables.add(quickInputService.createInputBox()); inputBox.prompt = localize('commands.hook.filename.prompt', "Enter hook file name"); inputBox.placeholder = localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"); - inputBox.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); + inputBox.title = localize('commands.hook.filename.title', "Hook File Name"); inputBox.buttons = [backButton]; inputBox.ignoreFocusOut = true; 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 4f926a701d4..e3ddd1312ad 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -718,10 +718,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const timeout = setTimeout(() => cts.cancel(), 5000); try { - let models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); - if (!models.length) { - models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' }); - } + const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); if (!models.length) { this.setFallbackTitle(); return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index df83eb5d194..7a5ba17dcaa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -6,17 +6,19 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; +import { status } from '../../../../../../base/browser/ui/aria/aria.js'; import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { localize2 } from '../../../../../../nls.js'; +import { localize, localize2 } from '../../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; -import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatTip, IChatTipService } from '../../chatTipService.js'; const $ = dom.$; @@ -29,6 +31,8 @@ export class ChatTipContentPart extends Disposable { private readonly _renderedContent = this._register(new MutableDisposable()); + private readonly _inChatTipContextKey: IContextKey; + constructor( tip: IChatTip, private readonly _renderer: IMarkdownRenderer, @@ -41,6 +45,16 @@ export class ChatTipContentPart extends Disposable { super(); this.domNode = $('.chat-tip-widget'); + this.domNode.tabIndex = 0; + this.domNode.setAttribute('role', 'region'); + this.domNode.setAttribute('aria-roledescription', localize('chatTipRoleDescription', "tip")); + + this._inChatTipContextKey = ChatContextKeys.inChatTip.bindTo(this._contextKeyService); + const focusTracker = this._register(dom.trackFocus(this.domNode)); + this._register(focusTracker.onDidFocus(() => this._inChatTipContextKey.set(true))); + this._register(focusTracker.onDidBlur(() => this._inChatTipContextKey.set(false))); + this._register({ dispose: () => this._inChatTipContextKey.reset() }); + this._renderTip(tip); this._register(this._chatTipService.onDidDismissTip(() => { @@ -69,12 +83,27 @@ export class ChatTipContentPart extends Disposable { })); } + hasFocus(): boolean { + return dom.isAncestorOfActiveElement(this.domNode); + } + + focus(): void { + this.domNode.focus(); + } + private _renderTip(tip: IChatTip): void { dom.clearNode(this.domNode); this.domNode.appendChild(renderIcon(Codicon.lightbulb)); const markdownContent = this._renderer.render(tip.content); this._renderedContent.value = markdownContent; this.domNode.appendChild(markdownContent.element); + const textContent = markdownContent.element.textContent ?? localize('chatTip', "Chat tip"); + const hasLink = /\[.*?\]\(.*?\)/.test(tip.content.value); + const ariaLabel = hasLink + ? localize('chatTipWithAction', "{0} Tab to the action.", textContent) + : textContent; + this.domNode.setAttribute('aria-label', ariaLabel); + status(ariaLabel); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index cc9cfd47f39..30d8e85c097 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -8,20 +8,72 @@ align-items: center; gap: 4px; margin-bottom: 8px; + padding: 6px 10px; + border-radius: 4px; + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); + background-color: var(--vscode-editorWidget-background); font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); + position: relative; + overflow: hidden; } .interactive-item-container .chat-tip-widget .codicon-lightbulb { font-size: 12px; - color: var(--vscode-descriptionForeground); + color: var(--vscode-notificationsWarningIcon-foreground); } .interactive-item-container .chat-tip-widget .rendered-markdown p { margin: 0; } +.chat-getting-started-tip-container { + position: absolute; + left: 12px; + right: 12px; + /* Initial vertical position; updated dynamically to align with input top */ + bottom: 96px; + display: flex; + justify-content: center; + /* No horizontal padding so width exactly matches input */ + padding: 0; + pointer-events: none; + z-index: 2; +} + +.chat-getting-started-tip-container .chat-tip-widget { + pointer-events: auto; + display: flex; + align-items: center; + gap: 4px; + width: 100%; + max-width: 100%; + box-sizing: border-box; + /* Match input inner padding (6px per side) so content edges align */ + padding: 6px; + /* Darker background for welcome-state tip, distinct from input */ + background-color: var(--vscode-editorWidget-background); + border-radius: 4px 4px 0 0; + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); + border-bottom: none; /* Seamless attachment to input below */ + box-shadow: none; + font-size: var(--vscode-chat-font-size-body-s); + font-family: var(--vscode-chat-font-family, inherit); + color: var(--vscode-descriptionForeground); + position: relative; + overflow: hidden; +} + +.chat-getting-started-tip-container .chat-tip-widget .codicon-lightbulb { + font-size: 12px; + color: var(--vscode-notificationsWarningIcon-foreground); +} + +.chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p { + margin: 0; +} + /* Override bubble styling for tip widget's rendered markdown in chat editor */ .interactive-session:not(.chat-widget > .interactive-session) { .interactive-item-container.interactive-request .value .chat-tip-widget .rendered-markdown { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 57267238df8..dd074702d2b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -109,6 +109,7 @@ import { IAccessibilityService } from '../../../../../platform/accessibility/com import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { ChatQuestionCarouselAutoReply } from './chatQuestionCarouselAutoReply.js'; const $ = dom.$; @@ -183,8 +184,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer>(); + private readonly _autoRepliedQuestionCarousels = new Set(); + private readonly _autoReply: ChatQuestionCarouselAutoReply; - private readonly _notifiedQuestionCarousels = new WeakSet(); + private _activeTipPart: ChatTipContentPart | undefined; + + private readonly _notifiedQuestionCarousels = new Set(); private readonly _questionCarouselToast = this._register(new DisposableStore()); private readonly chatContentMarkdownRenderer: IMarkdownRenderer; @@ -270,6 +275,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { @@ -301,6 +307,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), ); templateData.value.appendChild(tipPart.domNode); + this._activeTipPart = tipPart; templateData.elementDisposables.add(tipPart); templateData.elementDisposables.add(tipPart.onDidHide(() => { tipPart.domNode.remove(); + if (this._activeTipPart === tipPart) { + this._activeTipPart = undefined; + } })); + templateData.elementDisposables.add({ + dispose: () => { + if (this._activeTipPart === tipPart) { + this._activeTipPart = undefined; + } + } + }); } let inlineSlashCommandRendered = false; @@ -2063,7 +2094,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, part: ChatQuestionCarouselPart) => { // Mark the carousel as used and store the answers @@ -2116,13 +2149,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer handleSubmit(answers, fallbackPart) }); + this.maybeAutoReplyToQuestionCarousel(context, carousel, fallbackPart, answers => handleSubmit(answers, fallbackPart), modelName, requestMessageText); return fallbackPart; } - // If global auto-approve (yolo mode) is enabled, skip with defaults immediately - if (!carousel.isUsed && this.configService.getValue(ChatConfiguration.GlobalAutoApprove)) { - part.skip(); - } + this.maybeAutoReplyToQuestionCarousel(context, carousel, part, answers => handleSubmit(answers, part), modelName, requestMessageText); // Track the carousel for auto-skip when user submits a new message // Only add tracking if not already tracked (prevents duplicate tracking on re-render) @@ -2161,13 +2192,23 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.notifyWindowOnConfirmation')) { @@ -2236,6 +2279,61 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined) => Promise, + modelName: string | undefined, + requestMessageText: string | undefined, + ): void { + if (carousel.isUsed) { + return; + } + + // Use a stable key based on requestId + resolveId to prevent duplicate + // auto-replies across re-renders of the same logical carousel. + const stableKey = this._getCarouselStableKey(context, carousel); + if (stableKey) { + if (this._autoRepliedQuestionCarousels.has(stableKey)) { + return; + } + // Mark as in-progress before the async opt-in check to prevent + // duplicate prompts/requests from concurrent re-renders. + this._autoRepliedQuestionCarousels.add(stableKey); + } + + void this._autoReply.shouldAutoReply().then(shouldAutoReply => { + if (!shouldAutoReply) { + // Roll back the in-progress mark if auto-reply is not enabled. + if (stableKey) { + this._autoRepliedQuestionCarousels.delete(stableKey); + } + return; + } + + const cts = new CancellationTokenSource(); + part.addDisposable(toDisposable(() => { + cts.cancel(); + cts.dispose(); + })); + + this._autoReply.autoReply(carousel, submit, modelName, requestMessageText, cts.token).catch(err => { + this.logService.debug('#ChatQuestionCarousel: Auto reply failed', toErrorMessage(err)); + }); + }); + } + + + private getRequestMessageText(response: IChatResponseViewModel): string | undefined { + const requestId = response.requestId; + const items = response.session.getItems(); + const request = items.find(item => isRequestVM(item) && item.id === requestId) as IChatRequestViewModel | undefined; + return request?.messageText; + } + + + private removeCarouselFromTracking(context: IChatContentPartRenderContext, part: ChatQuestionCarouselPart): void { if (isResponseVM(context.element)) { const carousels = this.pendingQuestionCarousels.get(context.element.sessionResource); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 4e33ef0c176..50b40d59419 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -789,6 +789,21 @@ export class ChatListWidget extends Disposable { return this._renderer.editorsInUse(); } + /** + * Whether the active tip currently has focus. + */ + hasTipFocus(): boolean { + return this._renderer.hasTipFocus(); + } + + /** + * Focus the active tip, if any. + * @returns Whether a tip was focused. + */ + focusTip(): boolean { + return this._renderer.focusTip(); + } + /** * Get template data for a request ID. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts b/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts new file mode 100644 index 00000000000..eca6e160814 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts @@ -0,0 +1,455 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import Severity from '../../../../../base/common/severity.js'; +import { hasKey } from '../../../../../base/common/types.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IChatQuestion, IChatQuestionCarousel } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { ChatMessageRole, getTextResponseFromStream, ILanguageModelsService } from '../../common/languageModels.js'; +import { Event } from '../../../../../base/common/event.js'; + +const enum AutoReplyStorageKeys { + AutoReplyOptIn = 'chat.autoReply.optIn' +} + +/** + * Encapsulates the logic for automatically replying to question carousels, + * including opt-in state management, LLM-based answer resolution, fallback + * answer generation, and answer parsing/merging. + */ +export class ChatQuestionCarouselAutoReply extends Disposable { + + constructor( + @IConfigurationService private readonly configService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + @ILogService private readonly logService: ILogService, + @IStorageService private readonly storageService: IStorageService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + ) { + super(); + + // Clear out warning accepted state if the setting is disabled + this._register(Event.runAndSubscribe(this.configService.onDidChangeConfiguration, e => { + if (!e || e.affectsConfiguration(ChatConfiguration.AutoReply)) { + if (this.configService.getValue(ChatConfiguration.AutoReply) !== true) { + this.storageService.remove(AutoReplyStorageKeys.AutoReplyOptIn, StorageScope.APPLICATION); + } + } + })); + } + + async shouldAutoReply(): Promise { + if (!this.configService.getValue(ChatConfiguration.AutoReply)) { + return false; + } + return this.checkOptIn(); + } + + async autoReply( + carousel: IChatQuestionCarousel, + submit: (answers: Map | undefined) => Promise, + modelName: string | undefined, + requestMessageText: string | undefined, + token: CancellationToken, + ): Promise { + if (token.isCancellationRequested || carousel.isUsed || carousel.questions.length === 0) { + return; + } + + const fallbackAnswers = this.buildFallbackCarouselAnswers(carousel, requestMessageText); + let resolvedAnswers = fallbackAnswers; + + const modelId = await this.getModelId(modelName); + if (modelId && !token.isCancellationRequested) { + try { + const parsedAnswers = await this.requestAnswers(modelId, carousel, requestMessageText, token); + if (parsedAnswers.size > 0) { + resolvedAnswers = this.mergeAnswers(carousel, parsedAnswers, fallbackAnswers); + } + } catch (err) { + this.logService.debug('#ChatQuestionCarousel: Failed to resolve auto reply', toErrorMessage(err)); + } + } + + if (token.isCancellationRequested || carousel.isUsed) { + return; + } + + await submit(resolvedAnswers); + } + + // #region Opt-in + + private async checkOptIn(): Promise { + const optedIn = this.storageService.getBoolean(AutoReplyStorageKeys.AutoReplyOptIn, StorageScope.APPLICATION, false); + if (optedIn) { + return true; + } + + const promptResult = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('chat.autoReply.enable.title', 'Enable chat auto reply?'), + buttons: [ + { + label: localize('chat.autoReply.enable', 'Enable'), + run: () => true + }, + { + label: localize('chat.autoReply.disable', 'Disable'), + run: () => false + }, + ], + custom: { + icon: Codicon.warning, + disableCloseAction: true, + markdownDetails: [{ + markdown: new MarkdownString(localize('chat.autoReply.enable.details', 'Chat auto reply answers question carousels using the current model and may make unintended choices. Review your settings and outputs carefully.')), + }], + } + }); + + if (promptResult.result !== true) { + await this.configService.updateValue(ChatConfiguration.AutoReply, false); + return false; + } + + this.storageService.store(AutoReplyStorageKeys.AutoReplyOptIn, true, StorageScope.APPLICATION, StorageTarget.USER); + return true; + } + + // #endregion + + // #region LLM interaction + + private async getModelId(modelName: string | undefined): Promise { + if (!modelName) { + return undefined; + } + + let models = await this.languageModelsService.selectLanguageModels({ id: modelName }); + if (models.length > 0) { + return models[0]; + } + + if (modelName.startsWith('copilot/')) { + models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', family: modelName.replace(/^copilot\//, '') }); + return models[0]; + } + + return undefined; + } + + private buildPrompt(carousel: IChatQuestionCarousel, requestMessageText: string | undefined, strict: boolean): string { + const questions = carousel.questions.map(question => ({ + id: question.id, + type: question.type, + title: question.title, + message: typeof question.message === 'string' ? question.message : question.message?.value, + options: question.options?.map(option => ({ id: option.id, label: option.label })) ?? [], + allowFreeformInput: question.allowFreeformInput ?? false, + })); + + const contextLines: string[] = []; + if (requestMessageText) { + contextLines.push(`Original user request: ${JSON.stringify(requestMessageText)}`); + } + + return [ + 'Choose default answers for the following questions.', + 'Return a JSON object keyed by question id.', + 'For text questions, the value should be a string.', + 'For singleSelect questions, the value should be { "selectedId": string } or { "freeform": string }.', + 'For multiSelect questions, the value should be { "selectedIds": string[] } and may include { "freeform": string }.', + 'If a question allows freeform input and has no options, return a freeform answer based on the user request when possible.', + 'Use option ids from the provided options.', + ...contextLines, + 'Questions:', + JSON.stringify(questions), + strict ? 'Return ONLY valid JSON. Do not include markdown or explanations.' : undefined, + ].filter(Boolean).join('\n'); + } + + private async requestAnswers( + modelId: string, + carousel: IChatQuestionCarousel, + requestMessageText: string | undefined, + token: CancellationToken, + ): Promise> { + const prompt = this.buildPrompt(carousel, requestMessageText, false); + const response = await this.languageModelsService.sendChatRequest( + modelId, + new ExtensionIdentifier('core'), + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], + {}, + token, + ); + const responseText = await getTextResponseFromStream(response); + const parsedAnswers = this.parseAnswers(responseText, carousel); + if (parsedAnswers.size > 0 || token.isCancellationRequested) { + return parsedAnswers; + } + + const retryPrompt = this.buildPrompt(carousel, requestMessageText, true); + const retryResponse = await this.languageModelsService.sendChatRequest( + modelId, + new ExtensionIdentifier('core'), + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: retryPrompt }] }], + {}, + token, + ); + const retryText = await getTextResponseFromStream(retryResponse); + return this.parseAnswers(retryText, carousel); + } + + // #endregion + + // #region Answer parsing and resolution + + private parseAnswers(responseText: string, carousel: IChatQuestionCarousel): Map { + const parsed = this.tryParseJsonObject(responseText); + if (!parsed) { + return new Map(); + } + + const answers = new Map(); + for (const question of carousel.questions) { + const rawAnswer = parsed[question.id]; + const resolved = this.resolveAnswerFromRaw(question, rawAnswer); + if (resolved !== undefined) { + answers.set(question.id, resolved); + } + } + return answers; + } + + private mergeAnswers( + carousel: IChatQuestionCarousel, + resolvedAnswers: Map, + fallbackAnswers: Map, + ): Map { + const merged = new Map(); + for (const question of carousel.questions) { + const fallback = fallbackAnswers.get(question.id); + if (this.hasDefaultValue(question) && fallback !== undefined) { + merged.set(question.id, fallback); + continue; + } + if (resolvedAnswers.has(question.id)) { + merged.set(question.id, resolvedAnswers.get(question.id)!); + continue; + } + if (fallback !== undefined) { + merged.set(question.id, fallback); + } + } + return merged; + } + + private hasDefaultValue(question: IChatQuestion): boolean { + switch (question.type) { + case 'text': + return question.defaultValue !== undefined; + case 'singleSelect': + return typeof question.defaultValue === 'string'; + case 'multiSelect': + return Array.isArray(question.defaultValue) + ? question.defaultValue.length > 0 + : typeof question.defaultValue === 'string'; + } + } + + private resolveAnswerFromRaw(question: IChatQuestion, raw: unknown): unknown | undefined { + switch (question.type) { + case 'text': { + if (typeof raw === 'string') { + const value = raw.trim(); + return value.length > 0 ? value : undefined; + } + if (raw && typeof raw === 'object' && hasKey(raw, { value: true }) && typeof (raw as { value: unknown }).value === 'string') { + const value = (raw as { value: string }).value.trim(); + return value.length > 0 ? value : undefined; + } + return undefined; + } + case 'singleSelect': { + let selectedInput: string | undefined; + let freeformInput: string | undefined; + if (typeof raw === 'string') { + selectedInput = raw; + } else if (raw && typeof raw === 'object') { + if (hasKey(raw, { selectedId: true }) && typeof (raw as { selectedId: unknown }).selectedId === 'string') { + selectedInput = (raw as { selectedId: string }).selectedId; + } else if (hasKey(raw, { selectedLabel: true }) && typeof (raw as { selectedLabel: unknown }).selectedLabel === 'string') { + selectedInput = (raw as { selectedLabel: string }).selectedLabel; + } + if (hasKey(raw, { freeform: true }) && typeof (raw as { freeform: unknown }).freeform === 'string') { + freeformInput = (raw as { freeform: string }).freeform; + } + } + + if (freeformInput && freeformInput.trim().length > 0) { + return { selectedValue: undefined, freeformValue: freeformInput.trim() }; + } + + const match = selectedInput ? this.matchQuestionOption(question, selectedInput) : undefined; + if (match) { + return { selectedValue: match.value, freeformValue: undefined }; + } + return undefined; + } + case 'multiSelect': { + let selectedInputs: string[] = []; + let freeformInput: string | undefined; + if (Array.isArray(raw)) { + selectedInputs = raw.filter(item => typeof item === 'string') as string[]; + } else if (typeof raw === 'string') { + selectedInputs = raw.split(',').map(item => item.trim()).filter(item => item.length > 0); + } else if (raw && typeof raw === 'object') { + if (hasKey(raw, { selectedIds: true })) { + const selectedIdsValue = (raw as { selectedIds?: unknown }).selectedIds; + if (Array.isArray(selectedIdsValue)) { + selectedInputs = selectedIdsValue.filter((item: unknown): item is string => typeof item === 'string'); + } + } + if (hasKey(raw, { freeform: true }) && typeof (raw as { freeform?: unknown }).freeform === 'string') { + freeformInput = (raw as { freeform: string }).freeform; + } + } + + const selectedValues = selectedInputs + .map(input => this.matchQuestionOption(question, input)?.value) + .filter(value => value !== undefined); + const freeformValue = freeformInput?.trim(); + + if (selectedValues.length > 0 || (freeformValue && freeformValue.length > 0)) { + return { selectedValues, freeformValue }; + } + return undefined; + } + } + } + + private matchQuestionOption(question: IChatQuestion, rawInput: string): { id: string; value: unknown } | undefined { + const options = question.options ?? []; + if (!options.length) { + return undefined; + } + + const normalized = rawInput.trim().toLowerCase(); + const numeric = Number.parseInt(normalized, 10); + if (!Number.isNaN(numeric) && numeric > 0 && numeric <= options.length) { + const option = options[numeric - 1]; + return { id: option.id, value: option.value }; + } + + const exactId = options.find(option => option.id.toLowerCase() === normalized); + if (exactId) { + return { id: exactId.id, value: exactId.value }; + } + const exactLabel = options.find(option => option.label.toLowerCase() === normalized); + if (exactLabel) { + return { id: exactLabel.id, value: exactLabel.value }; + } + const partialLabel = options.find(option => option.label.toLowerCase().includes(normalized)); + if (partialLabel) { + return { id: partialLabel.id, value: partialLabel.value }; + } + + return undefined; + } + + // #endregion + + // #region Fallback answers + + buildFallbackCarouselAnswers(carousel: IChatQuestionCarousel, requestMessageText: string | undefined): Map { + const answers = new Map(); + for (const question of carousel.questions) { + const answer = this.getFallbackAnswerForQuestion(question, requestMessageText); + if (answer !== undefined) { + answers.set(question.id, answer); + } + } + return answers; + } + + private getFallbackAnswerForQuestion(question: IChatQuestion, requestMessageText: string | undefined): unknown { + const fallbackFreeform = requestMessageText?.trim() || localize('chat.questionCarousel.autoReplyFallback', 'OK'); + + switch (question.type) { + case 'text': + return question.defaultValue ?? fallbackFreeform; + case 'singleSelect': { + const defaultOptionId = typeof question.defaultValue === 'string' ? question.defaultValue : undefined; + const defaultOption = defaultOptionId ? question.options?.find(opt => opt.id === defaultOptionId) : undefined; + if (defaultOption) { + return { selectedValue: defaultOption.value, freeformValue: undefined }; + } + if (question.options && question.options.length > 0) { + return { selectedValue: question.options[0].value, freeformValue: undefined }; + } + if (question.allowFreeformInput) { + return { selectedValue: undefined, freeformValue: fallbackFreeform }; + } + return undefined; + } + case 'multiSelect': { + const defaultIds = Array.isArray(question.defaultValue) + ? question.defaultValue + : (typeof question.defaultValue === 'string' ? [question.defaultValue] : []); + const selectedValues = question.options + ?.filter(opt => defaultIds.includes(opt.id)) + .map(opt => opt.value) + .filter(value => value !== undefined) ?? []; + if (selectedValues.length > 0) { + return { selectedValues, freeformValue: undefined }; + } + if (question.options && question.options.length > 0) { + return { selectedValues: [question.options[0].value], freeformValue: undefined }; + } + if (question.allowFreeformInput) { + return { selectedValues: [], freeformValue: fallbackFreeform }; + } + return undefined; + } + } + } + + // #endregion + + // #region Utilities + + private tryParseJsonObject(text: string): Record | undefined { + const trimmed = text.trim(); + if (!trimmed) { + return undefined; + } + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed; + try { + const parsed = JSON.parse(candidate) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return undefined; + } + return undefined; + } + + // #endregion +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 8fa0fbb402f..9ed052f6af2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -78,6 +78,9 @@ import { IChatListItemTemplate } from './chatListRenderer.js'; import { ChatListWidget } from './chatListWidget.js'; import { ChatEditorOptions } from './chatOptions.js'; import { ChatViewWelcomePart, IChatSuggestedPrompts, IChatViewWelcomeContent } from '../viewsWelcome/chatViewWelcomeController.js'; +import { IChatTipService } from '../chatTipService.js'; +import { ChatTipContentPart } from './chatContentParts/chatTipContentPart.js'; +import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; const $ = dom.$; @@ -233,6 +236,9 @@ export class ChatWidget extends Disposable implements IChatWidget { private welcomeMessageContainer!: HTMLElement; private readonly welcomePart: MutableDisposable = this._register(new MutableDisposable()); + private gettingStartedTipContainer: HTMLElement | undefined; + private readonly _gettingStartedTipPart = this._register(new MutableDisposable()); + private readonly chatSuggestNextWidget: ChatSuggestNextWidget; private bodyDimension: dom.Dimension | undefined; @@ -368,6 +374,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService, + @IChatTipService private readonly chatTipService: IChatTipService, ) { super(); @@ -622,6 +629,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } else { this.listContainer = dom.append(this.container, $(`.interactive-list`)); dom.append(this.container, this.chatSuggestNextWidget.domNode); + this.gettingStartedTipContainer = dom.append(this.container, $('.chat-getting-started-tip-container', { style: 'display: none' })); this.createInput(this.container, { renderFollowups, renderStyle, renderInputToolbarBelowInput }); } @@ -742,6 +750,15 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.input.focusQuestionCarousel(); } + toggleTipFocus(): boolean { + if (this.listWidget.hasTipFocus()) { + this.focusInput(); + return true; + } + + return this.listWidget.focusTip(); + } + hasInputFocus(): boolean { return this.input.hasFocus(); } @@ -831,9 +848,33 @@ export class ChatWidget extends Disposable implements IChatWidget { */ private updateChatViewVisibility(): void { if (this.viewModel) { + const isStandardLayout = this.viewOptions.renderStyle !== 'compact' && this.viewOptions.renderStyle !== 'minimal'; const numItems = this.viewModel.getItems().length; dom.setVisibility(numItems === 0, this.welcomeMessageContainer); dom.setVisibility(numItems !== 0, this.listContainer); + + // Show/hide the getting-started tip container based on empty state. + // Only use this in the standard chat layout where the welcome view is shown. + if (isStandardLayout && this.gettingStartedTipContainer) { + if (numItems === 0) { + this.renderGettingStartedTipIfNeeded(); + this.container.classList.toggle('chat-has-getting-started-tip', !!this._gettingStartedTipPart.value); + } else { + // Dispose the cached tip part so the next empty state picks a + // fresh (rotated) tip instead of re-showing the stale one. + this._gettingStartedTipPart.clear(); + dom.clearNode(this.gettingStartedTipContainer); + // Reset inline positioning from layoutGettingStartedTipPosition + // so the next render starts from the CSS defaults. + this.gettingStartedTipContainer.style.top = ''; + this.gettingStartedTipContainer.style.bottom = ''; + this.gettingStartedTipContainer.style.left = ''; + this.gettingStartedTipContainer.style.right = ''; + this.gettingStartedTipContainer.style.width = ''; + dom.setVisibility(false, this.gettingStartedTipContainer); + this.container.classList.toggle('chat-has-getting-started-tip', false); + } + } } // Only show welcome getting started until extension is installed @@ -895,6 +936,84 @@ export class ChatWidget extends Disposable implements IChatWidget { } } + private renderGettingStartedTipIfNeeded(): void { + const tipContainer = this.gettingStartedTipContainer; + if (!tipContainer) { + return; + } + + // Already showing a tip + if (this._gettingStartedTipPart.value) { + dom.setVisibility(true, tipContainer); + return; + } + + const tip = this.chatTipService.getWelcomeTip(this.contextKeyService); + if (!tip) { + dom.setVisibility(false, tipContainer); + return; + } + + const store = new DisposableStore(); + const renderer = this.instantiationService.createInstance(ChatContentMarkdownRenderer); + const tipPart = store.add(this.instantiationService.createInstance(ChatTipContentPart, + tip, + renderer, + () => this.chatTipService.getWelcomeTip(this.contextKeyService), + )); + tipContainer.appendChild(tipPart.domNode); + + store.add(tipPart.onDidHide(() => { + tipPart.domNode.remove(); + this._gettingStartedTipPart.clear(); + dom.setVisibility(false, tipContainer); + this.container.classList.toggle('chat-has-getting-started-tip', false); + })); + + this._gettingStartedTipPart.value = store; + dom.setVisibility(true, tipContainer); + + // Best-effort synchronous position (works when layout is already settled, + // e.g. the very first render after page load). + this.layoutGettingStartedTipPosition(); + + // Also schedule a deferred correction for cases where the browser + // hasn't finished layout yet (e.g. returning to the welcome view + // after a conversation). + store.add(dom.scheduleAtNextAnimationFrame(dom.getWindow(tipContainer), () => { + this.layoutGettingStartedTipPosition(); + })); + } + + private layoutGettingStartedTipPosition(): void { + if (!this.container || !this.gettingStartedTipContainer || !this.inputPart) { + return; + } + + const inputContainer = this.inputPart.inputContainerElement; + if (!inputContainer) { + return; + } + + const containerRect = this.container.getBoundingClientRect(); + const inputRect = inputContainer.getBoundingClientRect(); + const tipRect = this.gettingStartedTipContainer.getBoundingClientRect(); + + // Align the tip horizontally with the input container. + const left = inputRect.left - containerRect.left; + this.gettingStartedTipContainer.style.left = `${left}px`; + this.gettingStartedTipContainer.style.right = 'auto'; + this.gettingStartedTipContainer.style.width = `${inputRect.width}px`; + + // Position the tip so its bottom edge sits flush against the input's + // top edge for a seamless visual connection. + const topOffset = inputRect.top - containerRect.top - tipRect.height; + if (topOffset > 0) { + this.gettingStartedTipContainer.style.top = `${topOffset}px`; + this.gettingStartedTipContainer.style.bottom = 'auto'; + } + } + private _getGenerateInstructionsMessage(): IMarkdownString { // Start checking for instruction files immediately if not already done if (!this._instructionFilesCheckPromise) { @@ -981,7 +1100,7 @@ export class ChatWidget extends Disposable implements IChatWidget { message: new MarkdownString(DISCLAIMER), icon: Codicon.chatSparkle, additionalMessage, - suggestedPrompts: this.getPromptFileSuggestions() + suggestedPrompts: this.getPromptFileSuggestions(), }; } @@ -1734,6 +1853,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this.layout(this.bodyDimension.height, this.bodyDimension.width); } + // Keep getting-started tip aligned with the top of the input + this.layoutGettingStartedTipPosition(); + this._onDidChangeContentHeight.fire(); })); } @@ -1857,6 +1979,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.inputPart.clearTodoListWidget(model.sessionResource, false); this.chatSuggestNextWidget.hide(); + this.chatTipService.resetSession(); this._codeBlockModelCollection.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index b0fd053396d..5d93d052632 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -289,12 +289,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatInputTodoListWidgetContainer!: HTMLElement; private chatQuestionCarouselContainer!: HTMLElement; private chatInputWidgetsContainer!: HTMLElement; + private inputContainer!: HTMLElement; private readonly _widgetController = this._register(new MutableDisposable()); private contextUsageWidget?: ChatContextUsageWidget; private contextUsageWidgetContainer!: HTMLElement; private readonly _contextUsageDisposables = this._register(new MutableDisposable()); + get inputContainerElement(): HTMLElement | undefined { + return this.inputContainer; + } + readonly height = observableValue(this, 0); private _inputEditor!: CodeEditorWidget; @@ -665,7 +670,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private initSelectedModel() { const persistedSelection = this.storageService.get(this.getSelectedModelStorageKey(), StorageScope.APPLICATION); - const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, persistedSelection === 'copilot/gpt-4.1'); + const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, true); if (persistedSelection) { const model = this.getModels().find(m => m.identifier === persistedSelection); @@ -1794,6 +1799,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.followupsContainer = elements.followupsContainer; const inputAndSideToolbar = elements.inputAndSideToolbar; // The chat input and toolbar to the right const inputContainer = elements.inputContainer; // The chat editor, attachments, and toolbars + this.inputContainer = inputContainer; const editorContainer = elements.editorContainer; this.attachmentsContainer = elements.attachmentsContainer; this.attachedContextContainer = elements.attachedContextContainer; 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 4de68fd4633..f3c634ff743 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -805,6 +805,12 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } +/* When a getting-started tip is shown, visually attach it to the input */ +.interactive-session.chat-has-getting-started-tip .chat-input-container { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + /* Context usage widget container - positioned at top right of chat input */ .interactive-session .chat-input-container .chat-context-usage-container { position: absolute; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index 306f8b863c9..43fc982f081 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -21,8 +21,8 @@ export interface IChatContextUsagePromptTokenDetail { } export interface IChatContextUsageData { - promptTokens: number; - maxInputTokens: number; + usedTokens: number; + totalContextWindow: number; percentage: number; promptTokenDetails?: readonly IChatContextUsagePromptTokenDetail[]; } @@ -102,14 +102,14 @@ export class ChatContextUsageDetails extends Disposable { } update(data: IChatContextUsageData): void { - const { percentage, promptTokens, maxInputTokens, promptTokenDetails } = data; + const { percentage, usedTokens, totalContextWindow, promptTokenDetails } = data; // Update token count and percentage on same line this.tokenCountLabel.textContent = localize( 'tokenCount', "{0} / {1} tokens", - this.formatTokenCount(promptTokens, 1), - this.formatTokenCount(maxInputTokens, 0) + this.formatTokenCount(usedTokens, 1), + this.formatTokenCount(totalContextWindow, 0) ); this.percentageLabel.textContent = `• ${percentage.toFixed(0)}%`; @@ -132,7 +132,10 @@ export class ChatContextUsageDetails extends Disposable { } private formatTokenCount(count: number, decimals: number): string { - if (count >= 1000000) { + // Use M when count is >= 1M, or when K representation would round to 1000K + const mThreshold = 1000000 - 500 * Math.pow(10, -decimals); + + if (count >= mThreshold) { return `${(count / 1000000).toFixed(decimals)}M`; } else if (count >= 1000) { return `${(count / 1000).toFixed(decimals)}K`; @@ -172,6 +175,16 @@ export class ChatContextUsageDetails extends Disposable { // Render each category for (const [category, items] of categoryMap) { + // Filter out items with 0% usage + const visibleItems = items.filter(item => { + const contextRelativePercentage = (item.percentageOfPrompt / 100) * contextWindowPercentage; + return contextRelativePercentage >= 0.05; // Show if at least 0.1% when rounded + }); + + if (visibleItems.length === 0) { + continue; + } + const categorySection = this.tokenDetailsContainer.appendChild($('.token-category')); // Category header @@ -179,7 +192,7 @@ export class ChatContextUsageDetails extends Disposable { categoryHeader.textContent = category; // Category items - for (const item of items) { + for (const item of visibleItems) { const itemRow = categorySection.appendChild($('.token-detail-item')); const itemLabel = itemRow.appendChild($('.token-detail-label')); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 422e3a3e905..5b48266f16a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -117,6 +117,7 @@ export class ChatContextUsageWidget extends Disposable { private currentData: IChatContextUsageData | undefined; private static readonly _OPENED_STORAGE_KEY = 'chat.contextUsage.hasBeenOpened'; + private static readonly _HOVER_ID = 'chat.contextUsage'; private readonly _contextUsageOpenedKey: IContextKey; @@ -170,6 +171,7 @@ export class ChatContextUsageWidget extends Disposable { } private readonly _hoverOptions: Omit = { + id: ChatContextUsageWidget._HOVER_ID, appearance: { showPointer: true, compact: true }, persistence: { hideOnHover: false }, trapFocus: true @@ -179,7 +181,9 @@ export class ChatContextUsageWidget extends Disposable { if (!this._isVisible.get() || !this.currentData) { return undefined; } - this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); + if (!this._contextUsageDetails.value) { + this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); + } this._contextUsageDetails.value.update(this.currentData); return this._contextUsageDetails.value; } @@ -244,23 +248,26 @@ export class ChatContextUsageWidget extends Disposable { const usage = response.usage; const modelMetadata = this.languageModelsService.lookupLanguageModel(modelId); const maxInputTokens = modelMetadata?.maxInputTokens; + const maxOutputTokens = modelMetadata?.maxOutputTokens; - if (!usage || !maxInputTokens || maxInputTokens <= 0) { + if (!usage || !maxInputTokens || maxInputTokens <= 0 || !maxOutputTokens || maxOutputTokens <= 0) { this.hide(); return; } const promptTokens = usage.promptTokens; const promptTokenDetails = usage.promptTokenDetails; - const percentage = Math.min(100, (promptTokens / maxInputTokens) * 100); + const totalContextWindow = maxInputTokens + maxOutputTokens; + const usedTokens = promptTokens + maxOutputTokens; + const percentage = Math.min(100, (usedTokens / totalContextWindow) * 100); - this.render(percentage, promptTokens, maxInputTokens, promptTokenDetails); + this.render(percentage, usedTokens, totalContextWindow, promptTokenDetails); this.show(); } - private render(percentage: number, promptTokens: number, maxTokens: number, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { + private render(percentage: number, usedTokens: number, totalContextWindow: number, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { // Store current data for use in details popup - this.currentData = { promptTokens, maxInputTokens: maxTokens, percentage, promptTokenDetails }; + this.currentData = { usedTokens, totalContextWindow, percentage, promptTokenDetails }; // Update pie chart progress this.progressIndicator.setProgress(percentage); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index cb507daf273..94bb20e75af 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -41,6 +41,7 @@ export namespace ChatContextKeys { export const inChatSession = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); export const inChatEditor = new RawContextKey('inChatEditor', false, { type: 'boolean', description: localize('inChatEditor', "Whether focus is in a chat editor.") }); export const inChatTodoList = new RawContextKey('inChatTodoList', false, { type: 'boolean', description: localize('inChatTodoList', "True when focus is in the chat todo list.") }); + export const inChatTip = new RawContextKey('inChatTip', false, { type: 'boolean', description: localize('inChatTip', "True when focus is in a chat tip.") }); export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index b55b824cd7d..5740a3345c7 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -437,15 +437,14 @@ export class ChatService extends Disposable implements IChatService { initialData: undefined, location, sessionResource, - sessionId, canUseTools: options?.canUseTools ?? true, disableBackgroundKeepAlive: options?.disableBackgroundKeepAlive }); } private _startSession(props: IStartSessionProps): ChatModel { - const { initialData, location, sessionResource, sessionId, canUseTools, transferEditingSession, disableBackgroundKeepAlive, inputState } = props; - const model = this.instantiationService.createInstance(ChatModel, initialData, { initialLocation: location, canUseTools, resource: sessionResource, sessionId, disableBackgroundKeepAlive, inputState }); + const { initialData, location, sessionResource, canUseTools, transferEditingSession, disableBackgroundKeepAlive, inputState } = props; + const model = this.instantiationService.createInstance(ChatModel, initialData, { initialLocation: location, canUseTools, resource: sessionResource, disableBackgroundKeepAlive, inputState }); if (location === ChatAgentLocation.Chat) { model.startEditingSession(true, transferEditingSession); } @@ -503,17 +502,17 @@ export class ChatService extends Disposable implements IChatService { return existingRef; } - const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); - if (!sessionId) { - throw new Error(`Cannot restore non-local session ${sessionResource}`); - } - let sessionData: ISerializedChatDataReference | undefined; if (isEqual(this.transferredSessionResource, sessionResource)) { this._transferredSessionResource = undefined; sessionData = await this._chatSessionStore.readTransferredSession(sessionResource); } else { - sessionData = await this._chatSessionStore.readSession(sessionId); + const localSessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); + if (localSessionId) { + sessionData = await this._chatSessionStore.readSession(localSessionId); + } else { + return this.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + } } if (!sessionData) { @@ -524,7 +523,6 @@ export class ChatService extends Disposable implements IChatService { initialData: sessionData, location: sessionData.value.initialLocation ?? ChatAgentLocation.Chat, sessionResource, - sessionId, canUseTools: true, }); @@ -550,7 +548,6 @@ export class ChatService extends Disposable implements IChatService { initialData: { value: data, serializer: new ChatSessionOperationLog() }, location: data.initialLocation ?? ChatAgentLocation.Chat, sessionResource, - sessionId, canUseTools: true, }); } @@ -568,6 +565,14 @@ export class ChatService extends Disposable implements IChatService { } const providedSession = await this.chatSessionService.getOrCreateChatSession(chatSessionResource, CancellationToken.None); + + // Make sure we haven't created this in the meantime + const existingRefAfterProvision = this._sessionModels.acquireExisting(chatSessionResource); + if (existingRefAfterProvision) { + providedSession.dispose(); + return existingRefAfterProvision; + } + const chatSessionType = chatSessionResource.scheme; // Contributed sessions do not use UI tools diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index a5eec8a6ad8..2ac63ee13ea 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -25,6 +25,7 @@ export enum ChatConfiguration { RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', InlineReferencesStyle = 'chat.inlineReferences.style', + AutoReply = 'chat.autoReply', GlobalAutoApprove = 'chat.tools.global.autoApprove', AutoApproveEdits = 'chat.tools.edits.autoApprove', AutoApprovedUrls = 'chat.tools.urls.autoApprove', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 4067c77f7e7..f01c341dd9a 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -221,6 +221,36 @@ export interface ILanguageModelChatResponse { result: Promise; } +export async function getTextResponseFromStream(response: ILanguageModelChatResponse): Promise { + let responseText = ''; + const streaming = (async () => { + if (!response?.stream) { + return; + } + for await (const part of response.stream) { + if (Array.isArray(part)) { + for (const item of part) { + if (item.type === 'text') { + responseText += item.value; + } + } + } else if (part.type === 'text') { + responseText += part.value; + } + } + })(); + + try { + await Promise.all([response.result, streaming]); + return responseText; + } catch (err) { + if (responseText) { + return responseText; + } + throw err; + } +} + export interface ILanguageModelChatProvider { readonly onDidChange: Event; provideLanguageModelChatInfo(options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 7f9170a04f2..3b741cb825a 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -37,7 +37,7 @@ import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentResult, IChatAgentService, UserSelectedTools, reviveSerializedAgent } from '../participants/chatAgents.js'; import { ChatRequestTextPart, IParsedChatRequest, reviveParsedChatRequest } from '../requestParser/chatParserTypes.js'; -import { LocalChatSessionUri } from './chatUri.js'; +import { chatSessionResourceToId, LocalChatSessionUri } from './chatUri.js'; import { ObjectMutationLog } from './objectMutationLog.js'; @@ -2102,7 +2102,7 @@ export class ChatModel extends Disposable implements IChatModel { constructor( dataRef: ISerializedChatDataReference | undefined, - initialModelProps: { initialLocation: ChatAgentLocation; canUseTools: boolean; inputState?: ISerializableChatModelInputState; resource?: URI; sessionId?: string; disableBackgroundKeepAlive?: boolean }, + initialModelProps: { initialLocation: ChatAgentLocation; canUseTools: boolean; inputState?: ISerializableChatModelInputState; resource?: URI; disableBackgroundKeepAlive?: boolean }, @ILogService private readonly logService: ILogService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatEditingService private readonly chatEditingService: IChatEditingService, @@ -2118,8 +2118,23 @@ export class ChatModel extends Disposable implements IChatModel { } this._isImported = !!initialData && isValidExportedData && !isValidFullData; - this._sessionId = (isValidFullData && initialData.sessionId) || initialModelProps.sessionId || generateUuid(); - this._sessionResource = initialModelProps.resource ?? LocalChatSessionUri.forSession(this._sessionId); + + // Set the session resource and id + if (initialModelProps.resource) { + // prefer using the provided resource if provided + this._sessionId = chatSessionResourceToId(initialModelProps.resource); + this._sessionResource = initialModelProps.resource; + } else if (isValidFullData) { + // Otherwise use the serialized id. This is only valid for local chat sessions + this._sessionId = initialData.sessionId; + this._sessionResource = LocalChatSessionUri.forSession(initialData.sessionId); + } else { + // Finally fall back to generating a new id for a local session. This is used in the case where a + // chat has been exported (but not serialized) + this._sessionId = generateUuid(); + this._sessionResource = LocalChatSessionUri.forSession(this._sessionId); + } + this._disableBackgroundKeepAlive = initialModelProps.disableBackgroundKeepAlive ?? false; this._requests = initialData ? this._deserialize(initialData) : []; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts index 42305065ed5..268dba2ec10 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts @@ -16,7 +16,6 @@ export interface IStartSessionProps { readonly initialData?: ISerializedChatDataReference; readonly location: ChatAgentLocation; readonly sessionResource: URI; - readonly sessionId?: string; readonly canUseTools: boolean; readonly transferEditingSession?: IChatEditingSession; readonly disableBackgroundKeepAlive?: boolean; diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index e0dd7ca927a..94097d98c96 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -186,18 +186,6 @@ export interface IChatAgentResultTimings { totalElapsed: number; } -export interface IChatAgentPromptTokenDetail { - category: string; - label: string; - percentageOfPrompt: number; -} - -export interface IChatAgentResultUsage { - promptTokens: number; - completionTokens: number; - promptTokenDetails?: readonly IChatAgentPromptTokenDetail[]; -} - export interface IChatAgentResult { errorDetails?: IChatResponseErrorDetails; timings?: IChatAgentResultTimings; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 7a603df5251..2ca88074c26 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -19,7 +19,7 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry, toPromptTextVariableEntry, PromptFileVariableKind, IPromptTextVariableEntry, ChatRequestToolReferenceEntry, toToolVariableEntry } from '../attachments/chatVariableEntries.js'; import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; -import { isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; +import { isInClaudeAgentsFolder, isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; import { PromptsType } from './promptTypes.js'; import { ParsedPromptFile } from './promptFileParser.js'; import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; @@ -33,9 +33,12 @@ export type InstructionsCollectionEvent = { agentInstructionsCount: number; listedInstructionsCount: number; totalInstructionsCount: number; + claudeRulesCount: number; + claudeMdCount: number; + claudeAgentsCount: number; }; export function newInstructionsCollectionEvent(): InstructionsCollectionEvent { - return { applyingInstructionsCount: 0, referencedInstructionsCount: 0, agentInstructionsCount: 0, listedInstructionsCount: 0, totalInstructionsCount: 0 }; + return { applyingInstructionsCount: 0, referencedInstructionsCount: 0, agentInstructionsCount: 0, listedInstructionsCount: 0, totalInstructionsCount: 0, claudeRulesCount: 0, claudeMdCount: 0, claudeAgentsCount: 0 }; } type InstructionsCollectionClassification = { @@ -44,6 +47,9 @@ type InstructionsCollectionClassification = { agentInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of agent instructions added (copilot-instructions.md and agents.md).' }; listedInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of instruction patterns added.' }; totalInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of instruction entries added to variables.' }; + claudeRulesCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Claude rules files (.claude/rules/) added via pattern matching.' }; + claudeMdCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of CLAUDE.md agent instruction files added.' }; + claudeAgentsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Claude agent files (.claude/agents/) listed as subagents.' }; owner: 'digitarald'; comment: 'Tracks automatic instruction collection usage in chat prompt system.'; }; @@ -100,7 +106,7 @@ export class ComputeAutomaticInstructions { // get copilot instructions await this._addAgentInstructions(variables, telemetryEvent, token); - const instructionsListVariable = await this._getInstructionsWithPatternsList(instructionFiles, variables, token); + const instructionsListVariable = await this._getInstructionsWithPatternsList(instructionFiles, variables, telemetryEvent, token); if (instructionsListVariable) { variables.add(instructionsListVariable); telemetryEvent.listedInstructionsCount++; @@ -159,6 +165,9 @@ export class ComputeAutomaticInstructions { variables.add(toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, reason, true)); telemetryEvent.applyingInstructionsCount++; + if (isClaudeRules) { + telemetryEvent.claudeRulesCount++; + } } else { this._logService.trace(`[InstructionsContextComputer] No match for ${uri} with ${pattern}`); } @@ -199,6 +208,9 @@ export class ComputeAutomaticInstructions { } telemetryEvent.agentInstructionsCount++; + if (type === AgentFileType.claudeMd) { + telemetryEvent.claudeMdCount++; + } logger.logInfo(`Agent instruction file added: ${uri.toString()}`); } @@ -278,7 +290,7 @@ export class ComputeAutomaticInstructions { return undefined; } - private async _getInstructionsWithPatternsList(instructionFiles: readonly IPromptPath[], _existingVariables: ChatRequestVariableSet, token: CancellationToken): Promise { + private async _getInstructionsWithPatternsList(instructionFiles: readonly IPromptPath[], _existingVariables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { const readTool = this._getTool('readFile'); const runSubagentTool = this._getTool(VSCodeToolReference.runSubagent); @@ -398,6 +410,9 @@ export class ComputeAutomaticInstructions { entries.push(`${agent.argumentHint}`); } entries.push(''); + if (isInClaudeAgentsFolder(agent.uri)) { + telemetryEvent.claudeAgentsCount++; + } } } entries.push('', '', ''); // add trailing newline 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 4b9621c5547..89d415a99ef 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -108,7 +108,7 @@ export namespace PromptsConfig { /** * Configuration key for chat hooks usage. */ - export const USE_CHAT_HOOKS = 'chat.useChatHooks'; + export const USE_CHAT_HOOKS = 'chat.useHooks'; /** * Configuration key for enabling stronger skill adherence prompt (experimental). 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 186fee36470..db61519b2b8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -204,6 +204,14 @@ function isInAgentsFolder(fileUri: URI): boolean { return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); } +/** + * Helper function to check if a file is directly in the .claude/agents/ folder. + */ +export function isInClaudeAgentsFolder(fileUri: URI): boolean { + const dir = dirname(fileUri.path); + return dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); +} + /** * Helper function to check if a file is inside the .claude/rules/ folder (including subfolders). * Claude rules files (.md) in this folder are treated as instruction files. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index c159acfa4c3..eb567363a8e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -128,28 +128,9 @@ export function parseClaudeHooks( const commands: IHookCommand[] = []; for (const item of hookArray) { - if (!item || typeof item !== 'object') { - continue; - } - - const itemObj = item as Record; - - // Claude can have nested hooks with matchers: { matcher: "Bash", hooks: [...] } - const nestedHooks = (itemObj as { hooks?: unknown }).hooks; - if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { - for (const nestedHook of nestedHooks) { - const resolved = resolveClaudeCommand(nestedHook as Record, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } - } else { - // Direct hook command - const resolved = resolveClaudeCommand(itemObj, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } + // Use shared helper that handles both direct commands and nested matcher structures + const extracted = extractHookCommandsFromItem(item, workspaceRootUri, userHome); + commands.push(...extracted); } if (commands.length > 0) { @@ -166,19 +147,59 @@ export function parseClaudeHooks( } /** - * Resolves a Claude hook command to our IHookCommand format. - * Claude commands can be: { type: "command", command: "..." } or { command: "..." } + * Helper to extract hook commands from an item that could be: + * 1. A direct command object: { type: 'command', command: '...' } + * 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] } + * + * This allows Copilot format to handle Claude-style entries if pasted. + * Also handles Claude's leniency where 'type' field can be omitted. */ -function resolveClaudeCommand( - raw: Record, +export function extractHookCommandsFromItem( + item: unknown, workspaceRootUri: URI | undefined, userHome: string -): IHookCommand | undefined { - // Claude might not require 'type' field, so we're more lenient - const hasValidType = raw.type === undefined || raw.type === 'command'; - if (!hasValidType) { - return undefined; +): IHookCommand[] { + if (!item || typeof item !== 'object') { + return []; } - return resolveHookCommand(raw, workspaceRootUri, userHome); + const itemObj = item as Record; + const commands: IHookCommand[] = []; + + // Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] } + const nestedHooks = itemObj.hooks; + if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { + for (const nestedHook of nestedHooks) { + if (!nestedHook || typeof nestedHook !== 'object') { + continue; + } + const normalized = normalizeForResolve(nestedHook as Record); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + } else { + // Direct command object + const normalized = normalizeForResolve(itemObj); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + + return commands; +} + +/** + * Normalizes a hook command object for resolving. + * Claude format allows omitting the 'type' field, treating it as 'command'. + * This ensures compatibility when Claude-style hooks are pasted into Copilot format. + */ +function normalizeForResolve(raw: Record): Record { + // If type is missing or already 'command', ensure it's set to 'command' + if (raw.type === undefined || raw.type === 'command') { + return { ...raw, type: 'command' }; + } + return raw; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index 6bdf4afdc89..d00fd26cb1e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -5,8 +5,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../base/common/path.js'; -import { HookType, IHookCommand, toHookType, resolveHookCommand } from './hookSchema.js'; -import { parseClaudeHooks } from './hookClaudeCompat.js'; +import { HookType, IHookCommand, toHookType } from './hookSchema.js'; +import { parseClaudeHooks, extractHookCommandsFromItem } from './hookClaudeCompat.js'; import { resolveCopilotCliHookType } from './hookCopilotCliCompat.js'; /** @@ -97,10 +97,9 @@ export function parseCopilotHooks( const commands: IHookCommand[] = []; for (const item of hookArray) { - const resolved = resolveHookCommand(item as Record, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } + // Use helper that handles both direct commands and Claude-style nested matcher structures + const extracted = extractHookCommandsFromItem(item, workspaceRootUri, userHome); + commands.push(...extracted); } if (commands.length > 0) { 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 7d4cb5cc352..e32abc667f0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -177,7 +177,10 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedHooks = this._register(new CachedPromise( (token) => this.computeHooks(token), - () => this.getFileLocatorEvent(PromptsType.hook) + () => Event.any( + this.getFileLocatorEvent(PromptsType.hook), + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS)), + ) )); // Hack: Subscribe to activate caching (CachedPromise only caches when onDidChange has listeners) @@ -1000,6 +1003,11 @@ export class PromptsService extends Disposable implements IPromptsService { } private async computeHooks(token: CancellationToken): Promise { + const useChatHooks = this.configurationService.getValue(PromptsConfig.USE_CHAT_HOOKS); + if (!useChatHooks) { + return undefined; + } + const hookFiles = await this.listPromptFiles(PromptsType.hook, token); if (hookFiles.length === 0) { diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index 9aca92e5856..d756c702087 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -4,15 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { Location } from '../../../../../../editor/common/languages.js'; -import { getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ChatResponseAccessibleView, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; suite('ChatResponseAccessibleView', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); suite('getToolSpecificDataDescription', () => { test('returns empty string for undefined', () => { @@ -388,4 +392,47 @@ suite('ChatResponseAccessibleView', () => { assert.ok(result.includes('test.ts')); }); }); + + suite('getProvider', () => { + test('prefers the latest response when focus is on a queued request', () => { + const instantiationService = store.add(new TestInstantiationService()); + const responseItem = { + response: { value: [{ kind: 'markdownContent', content: new MarkdownString('Response content') }] }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const queuedRequest = { message: 'Queued request' }; + const items = [responseItem, queuedRequest]; + let focusedItem: unknown = queuedRequest; + + const widget = { + hasInputFocus: () => true, + focusResponseItem: () => { focusedItem = queuedRequest; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + assert.ok(provider.provideContent().includes('Response content')); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 3ee8fdae9f6..a1f7741a4b7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -479,6 +479,39 @@ suite('ChatTipService', () => { assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); }); + test('resetSession allows tips in a new conversation', () => { + const service = createService(); + const now = Date.now(); + + // Show a tip in the first conversation + const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip1, 'First request should get a tip'); + + // Second request — no tip (one per session) + const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); + assert.strictEqual(tip2, undefined, 'Second request should not get a tip'); + + // Start a new conversation + service.resetSession(); + + // New request after reset should get a tip + const tip3 = service.getNextTip('request-3', Date.now() + 1000, contextKeyService); + assert.ok(tip3, 'First request after resetSession should get a tip'); + }); + + test('chatResponse tip shows regardless of welcome tip', () => { + const service = createService(); + const now = Date.now(); + + // Show a welcome tip (simulating the getting-started view) + const welcomeTip = service.getWelcomeTip(contextKeyService); + assert.ok(welcomeTip, 'Welcome tip should be shown'); + + // First new request should still get a chatResponse tip + const tip = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip, 'ChatResponse tip should show even when welcome tip was shown'); + }); + test('excludes tip when tracked tool has been invoked', () => { const mockToolsService = createMockToolsService(); const tip: ITipDefinition = { @@ -580,6 +613,47 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); + test('excludes tip when requiresAnyToolSetRegistered tool sets are not registered', () => { + const tip: ITipDefinition = { + id: 'tip.githubRepo', + message: 'test', + requiresAnyToolSetRegistered: ['github', 'github-pull-request'], + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when no required tool sets are registered'); + }); + + test('does not exclude tip when at least one requiresAnyToolSetRegistered tool set is registered', () => { + const mockToolsService = createMockToolsService(); + mockToolsService.addRegisteredToolSetName('github'); + + const tip: ITipDefinition = { + id: 'tip.githubRepo', + message: 'test', + requiresAnyToolSetRegistered: ['github', 'github-pull-request'], + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + mockToolsService, + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when at least one required tool set is registered'); + }); + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index b53e9e8fd1b..5733ddff827 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -27,7 +27,7 @@ import { testWorkspace } from '../../../../../../platform/workspace/test/common/ import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { TestContextService, TestUserDataProfileService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { ComputeAutomaticInstructions } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; +import { ComputeAutomaticInstructions, InstructionsCollectionEvent } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/promptTypes.js'; @@ -871,13 +871,9 @@ suite('ComputeAutomaticInstructions', () => { '---', 'applyTo: "**/*.ts"', '---', - 'TS instructions [](./referenced.instructions.md)', + 'TS instructions', ] }, - { - path: `${rootFolder}/.github/instructions/referenced.instructions.md`, - contents: ['Referenced content'], - }, { path: `${rootFolder}/.github/copilot-instructions.md`, contents: ['Copilot instructions'], @@ -908,16 +904,174 @@ suite('ComputeAutomaticInstructions', () => { const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); assert.ok(telemetryEvent, 'Should emit telemetry event'); - const data = telemetryEvent.data as { - applyingInstructionsCount: number; - referencedInstructionsCount: number; - agentInstructionsCount: number; - totalInstructionsCount: number; - }; - assert.ok(data.applyingInstructionsCount >= 0, 'Should have applying count'); - assert.ok(data.referencedInstructionsCount >= 0, 'Should have referenced count'); - assert.ok(data.agentInstructionsCount >= 0, 'Should have agent count'); - assert.ok(data.totalInstructionsCount >= 0, 'Should have total count'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + assert.deepStrictEqual(data, { + applyingInstructionsCount: 1, + referencedInstructionsCount: 0, + agentInstructionsCount: 2, + listedInstructionsCount: 0, + totalInstructionsCount: 3, + claudeRulesCount: 0, + claudeMdCount: 0, + claudeAgentsCount: 0, + }); + }); + + test('should track Claude rules in telemetry', async () => { + const rootFolderName = 'telemetry-claude-rules-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/rules/code-style.md`, + contents: ['Code style guidelines'], + }, + { + path: `${rootFolder}/.claude/rules/testing.md`, + contents: [ + '---', + 'paths:', + ' - "**/*.test.ts"', + '---', + 'Testing guidelines', + ], + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const telemetryEvents: { eventName: string; data: unknown }[] = []; + const mockTelemetryService = { + publicLog2: (eventName: string, data: unknown) => { + telemetryEvents.push({ eventName, data }); + } + } as unknown as ITelemetryService; + instaService.stub(ITelemetryService, mockTelemetryService); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); + assert.ok(telemetryEvent, 'Should emit telemetry event'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + // code-style.md defaults to ** so should match; testing.md only matches *.test.ts so should not match + assert.strictEqual(data.claudeRulesCount, 1, 'Should count 1 Claude rules file (code-style.md matches **)'); + assert.strictEqual(data.applyingInstructionsCount, 1, 'Claude rules count as applying instructions'); + assert.strictEqual(data.claudeMdCount, 0, 'Should have no CLAUDE.md count'); + }); + + test('should track CLAUDE.md in telemetry', async () => { + const rootFolderName = 'telemetry-claudemd-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/CLAUDE.md`, + contents: ['Claude guidelines'], + }, + { + path: `${rootFolder}/.claude/CLAUDE.md`, + contents: ['More Claude guidelines'], + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const telemetryEvents: { eventName: string; data: unknown }[] = []; + const mockTelemetryService = { + publicLog2: (eventName: string, data: unknown) => { + telemetryEvents.push({ eventName, data }); + } + } as unknown as ITelemetryService; + instaService.stub(ITelemetryService, mockTelemetryService); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); + assert.ok(telemetryEvent, 'Should emit telemetry event'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + assert.strictEqual(data.claudeMdCount, 2, 'Should count both CLAUDE.md files'); + assert.strictEqual(data.claudeRulesCount, 0, 'Should have no Claude rules count'); + }); + + test('should track Claude agents in telemetry', async () => { + const rootFolderName = 'telemetry-claude-agents-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true); + testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { + [AGENTS_SOURCE_FOLDER]: true, + '.claude/agents': true, + }); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/agents/claude-agent.agent.md`, + contents: [ + '---', + 'description: \'A Claude agent\'', + '---', + 'Claude agent content', + ] + }, + { + path: `${rootFolder}/.github/agents/gh-agent.agent.md`, + contents: [ + '---', + 'description: \'A GitHub agent\'', + '---', + 'GitHub agent content', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const telemetryEvents: { eventName: string; data: unknown }[] = []; + const mockTelemetryService = { + publicLog2: (eventName: string, data: unknown) => { + telemetryEvents.push({ eventName, data }); + } + } as unknown as ITelemetryService; + instaService.stub(ITelemetryService, mockTelemetryService); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_runSubagent': true }, + ['*'] + ); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); + assert.ok(telemetryEvent, 'Should emit telemetry event'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + assert.strictEqual(data.claudeAgentsCount, 1, 'Should count 1 Claude agent'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 6f852ed7285..76b2ac54b07 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -6,13 +6,98 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { HookType } from '../../../common/promptSyntax/hookSchema.js'; -import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName } from '../../../common/promptSyntax/hookClaudeCompat.js'; +import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName, extractHookCommandsFromItem } from '../../../common/promptSyntax/hookClaudeCompat.js'; import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; suite('HookClaudeCompat', () => { ensureNoDisposablesAreLeakedInTestSuite(); + suite('extractHookCommandsFromItem', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + test('extracts direct command object', () => { + const item = { type: 'command', command: 'echo "test"' }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "test"'); + }); + + test('extracts from nested matcher structure', () => { + const item = { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "nested"' } + ] + }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "nested"'); + }); + + test('extracts multiple hooks from matcher structure', () => { + const item = { + matcher: 'Write', + hooks: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].command, 'echo "first"'); + assert.strictEqual(result[1].command, 'echo "second"'); + }); + + test('handles command without type field (Claude format)', () => { + const item = { command: 'echo "no type"' }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "no type"'); + }); + + test('handles nested command without type field', () => { + const item = { + matcher: 'Bash', + hooks: [ + { command: 'echo "no type nested"' } + ] + }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "no type nested"'); + }); + + test('returns empty array for null item', () => { + const result = extractHookCommandsFromItem(null, workspaceRoot, userHome); + assert.strictEqual(result.length, 0); + }); + + test('returns empty array for undefined item', () => { + const result = extractHookCommandsFromItem(undefined, workspaceRoot, userHome); + assert.strictEqual(result.length, 0); + }); + + test('returns empty array for invalid type', () => { + const item = { type: 'script', command: 'echo "wrong type"' }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 0); + }); + }); + suite('resolveClaudeHookType', () => { test('resolves PreToolUse', () => { assert.strictEqual(resolveClaudeHookType('PreToolUse'), HookType.PreToolUse); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts index 7d4ba6ffe51..ede5eeb5e52 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts @@ -52,6 +52,94 @@ suite('HookCompatibility', () => { assert.strictEqual(result.size, 0); }); }); + + suite('Claude-style matcher compatibility', () => { + test('parses Claude-style nested matcher structure', () => { + // When Claude format is pasted into Copilot hooks file + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "from matcher"' } + ] + } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "from matcher"'); + }); + + test('parses Claude-style nested matcher with multiple hooks', () => { + const json = { + hooks: { + PostToolUse: [ + { + matcher: 'Write', + hooks: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PostToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "first"'); + assert.strictEqual(entry.hooks[1].command, 'echo "second"'); + }); + + test('handles mixed direct and nested matcher entries', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "direct"' }, + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "nested"' } + ] + } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "direct"'); + assert.strictEqual(entry.hooks[1].command, 'echo "nested"'); + }); + + test('handles Claude-style hook without type field', () => { + // Claude allows omitting the type field + const json = { + hooks: { + SessionStart: [ + { command: 'echo "no type"' } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.SessionStart)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "no type"'); + }); + }); }); suite('parseHooksFromFile', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index ea248c48bd2..665bd2a4618 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -27,6 +27,9 @@ export class MockLanguageModelToolsService extends Disposable implements ILangua private readonly _onDidInvokeTool = this._register(new Emitter()); + private readonly _registeredToolIds = new Set(); + private readonly _registeredToolSetNames = new Set(); + constructor() { super(); } @@ -88,7 +91,14 @@ export class MockLanguageModelToolsService extends Disposable implements ILangua return []; } + addRegisteredToolId(id: string): void { + this._registeredToolIds.add(id); + } + getTool(id: string): IToolData | undefined { + if (this._registeredToolIds.has(id)) { + return { id, source: ToolDataSource.Internal, displayName: id, modelDescription: id }; + } return undefined; } @@ -125,7 +135,14 @@ export class MockLanguageModelToolsService extends Disposable implements ILangua return []; } + addRegisteredToolSetName(name: string): void { + this._registeredToolSetNames.add(name); + } + getToolSetByName(name: string): IToolSet | undefined { + if (this._registeredToolSetNames.has(name)) { + return { id: name, referenceName: name, icon: ThemeIcon.fromId(Codicon.tools.id), source: ToolDataSource.Internal, getTools: () => [] }; + } return undefined; } diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css index 2c59b893326..982fbc1d50b 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css @@ -109,7 +109,6 @@ } .settings-editor > .settings-header > .settings-header-controls .last-synced-label { - padding-top: 7px; opacity: 0.9; } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 9a33bfea060..4c7e17c9cc3 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -93,13 +93,8 @@ export function createGroupIterator(group: SettingsTreeGroupElement): Iterable(SCROLL_BEHAVIOR_KEY); - if (this.searchResultModel || scrollBehavior === 'paginated') { - // In search mode or paginated mode, filter to show only the selected category - if (this.viewState.categoryFilter !== element) { - this.viewState.categoryFilter = element ?? undefined; - // Force render in this case, because - // onDidClickSetting relies on the updated view. - this.renderTree(undefined, true); - this.settingsTree.scrollTop = 0; - } - } else { - // In continuous mode, clear any category filter that may have been set in paginated mode - if (this.viewState.categoryFilter) { - this.viewState.categoryFilter = undefined; - this.renderTree(undefined, true); - } - if (element && (!e.browserEvent || !(e.browserEvent).fromScroll)) { - let targetElement = element; - // Searches equivalent old Object currently living in the Tree nodes. - if (!this.settingsTree.hasElement(targetElement)) { - if (element instanceof SettingsTreeGroupElement) { - const targetId = element.id; - const findInViewNodes = (nodes: any[]): SettingsTreeGroupElement | undefined => { - for (const node of nodes) { - if (node.element instanceof SettingsTreeGroupElement && node.element.id === targetId) { - return node.element; - } - if (node.children && node.children.length > 0) { - const found = findInViewNodes(node.children); - if (found) { - return found; - } - } - } - return undefined; - }; - - try { - const rootNode = this.settingsTree.getNode(null); - if (rootNode && rootNode.children) { - const foundOldElement = findInViewNodes(rootNode.children); - if (foundOldElement) { - // Now we don't reveal the New Object, reveal the Old Object" - targetElement = foundOldElement; - } - } - } catch (err) { - // Tree might be in an invalid state, ignore - } - } - } - - if (this.settingsTree.hasElement(targetElement)) { - this.settingsTree.reveal(targetElement, 0); - this.settingsTree.setFocus([targetElement]); - } - } + // Filter to show only the selected category + if (this.viewState.categoryFilter !== element) { + this.viewState.categoryFilter = element ?? undefined; + // Force render in this case, because + // onDidClickSetting relies on the updated view. + this.renderTree(undefined, true); + this.settingsTree.scrollTop = 0; } })); @@ -1263,74 +1208,6 @@ export class SettingsEditor2 extends EditorPane { private updateTreeScrollSync(): void { this.settingRenderers.cancelSuggesters(); - if (this.searchResultModel) { - return; - } - - // In paginated mode, we don't sync scroll position since categories are filtered - const scrollBehavior = this.configurationService.getValue<'paginated' | 'continuous'>(SCROLL_BEHAVIOR_KEY); - if (scrollBehavior === 'paginated') { - return; - } - - if (!this.tocTreeModel) { - return; - } - - const elementToSync = this.settingsTree.firstVisibleElement; - const element = elementToSync instanceof SettingsTreeSettingElement ? elementToSync.parent : - elementToSync instanceof SettingsTreeGroupElement ? elementToSync : - null; - - // It's possible for this to be called when the TOC and settings tree are out of sync - e.g. when the settings tree has deferred a refresh because - // it is focused. So, bail if element doesn't exist in the TOC. - let nodeExists = true; - try { this.tocTree.getNode(element); } catch (e) { nodeExists = false; } - if (!nodeExists) { - return; - } - - if (element && this.tocTree.getSelection()[0] !== element) { - const ancestors = this.getAncestors(element); - ancestors.forEach(e => this.tocTree.expand(e)); - - this.tocTree.reveal(element); - const elementTop = this.tocTree.getRelativeTop(element); - if (typeof elementTop !== 'number') { - return; - } - - this.tocTree.collapseAll(); - - ancestors.forEach(e => this.tocTree.expand(e)); - if (elementTop < 0 || elementTop > 1) { - this.tocTree.reveal(element); - } else { - this.tocTree.reveal(element, elementTop); - } - - this.tocTree.expand(element); - - this.tocTree.setSelection([element]); - - const fakeKeyboardEvent = new KeyboardEvent('keydown'); - (fakeKeyboardEvent).fromScroll = true; - this.tocTree.setFocus([element], fakeKeyboardEvent); - } - } - - private getAncestors(element: SettingsTreeElement): SettingsTreeElement[] { - const ancestors: SettingsTreeElement[] = []; - - while (element.parent) { - if (element.parent.id !== 'root') { - ancestors.push(element.parent); - } - - element = element.parent; - } - - return ancestors.reverse(); } private updateChangedSetting(key: string, value: unknown, manualReset: boolean, languageFilter: string | undefined, scope: ConfigurationScope | undefined): Promise { @@ -1710,17 +1587,14 @@ export class SettingsEditor2 extends EditorPane { } else { this.refreshTOCTree(); - // In paginated mode, set initial category to the first one (Commonly Used) - const scrollBehavior = this.configurationService.getValue<'paginated' | 'continuous'>(SCROLL_BEHAVIOR_KEY); - if (scrollBehavior === 'paginated') { - const rootChildren = this.settingsTreeModel.value.root.children; - if (Array.isArray(rootChildren) && rootChildren.length > 0) { - const firstCategory = rootChildren[0]; - if (firstCategory instanceof SettingsTreeGroupElement) { - this.viewState.categoryFilter = firstCategory; - this.tocTree.setFocus([firstCategory]); - this.tocTree.setSelection([firstCategory]); - } + // Set initial category to the first one (Commonly Used) + const rootChildren = this.settingsTreeModel.value.root.children; + if (Array.isArray(rootChildren) && rootChildren.length > 0) { + const firstCategory = rootChildren[0]; + if (firstCategory instanceof SettingsTreeGroupElement) { + this.viewState.categoryFilter = firstCategory; + this.tocTree.setFocus([firstCategory]); + this.tocTree.setSelection([firstCategory]); } } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index bc3a037c246..63a328d454b 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -209,8 +209,7 @@ export const tocData: ITOCEntry = { 'chat.notifyWindow*', 'chat.statusWidget.*', 'chat.tips.*', - 'chat.unifiedAgentsBar.*', - 'chat.confettiOnThumbsUp' + 'chat.unifiedAgentsBar.*' ] }, { @@ -258,7 +257,7 @@ export const tocData: ITOCEntry = { 'chat.useNestedAgentsMdFiles', 'chat.useAgentSkills', 'chat.experimental.useSkillAdherencePrompt', - 'chat.useChatHooks', + 'chat.useHooks', 'chat.includeApplyingInstructions', 'chat.includeReferencedInstructions', 'chat.sendElementsToChat.*', diff --git a/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts b/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts index 1b1e55f4529..e5cec6f206a 100644 --- a/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts +++ b/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts @@ -125,17 +125,6 @@ registry.registerConfiguration({ 'description': nls.localize('settingsSearchTocBehavior', "Controls the behavior of the Settings editor Table of Contents while searching. If this setting is being changed in the Settings editor, the setting will take effect after the search query is modified."), 'default': 'filter', 'scope': ConfigurationScope.WINDOW - }, - 'workbench.settings.scrollBehavior': { - 'type': 'string', - 'enum': ['paginated', 'continuous'], - 'enumDescriptions': [ - nls.localize('settingsScrollBehavior.paginated', "Show only settings from the selected category. Clicking a category in the Table of Contents filters the view to that category."), - nls.localize('settingsScrollBehavior.continuous', "Show all settings in a continuous scrolling list. Clicking a category scrolls to that section."), - ], - 'description': nls.localize('settingsScrollBehavior', "Controls whether the Settings editor shows one category at a time (paginated) or allows continuous scrolling through all settings."), - 'default': 'paginated', - 'scope': ConfigurationScope.WINDOW } } }); diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 87c3d392b20..e97facbaa35 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -342,7 +342,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.TerminalTitle]: { 'type': 'string', - 'default': '${process}', + 'default': '${sequence}', 'markdownDescription': terminalTitle }, [TerminalSettingId.TerminalDescription]: { @@ -589,13 +589,10 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.EnableKittyKeyboardProtocol]: { restricted: true, - markdownDescription: localize('terminal.integrated.enableKittyKeyboardProtocol', "Whether to enable the kitty keyboard protocol, which provides more detailed keyboard input reporting to the terminal."), + markdownDescription: localize('terminal.integrated.enableKittyKeyboardProtocol', "Whether to enable the kitty keyboard protocol, which allows a program in the terminal to request more detailed keyboard input reporting. This can, for example, enable `Shift+Enter` to be handled by the program."), type: 'boolean', - default: false, - tags: ['experimental', 'advanced'], - experiment: { - mode: 'auto' - } + default: true, + tags: ['advanced'] }, [TerminalSettingId.EnableWin32InputMode]: { restricted: true, diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 81bc2516e21..0eb0cf84c48 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -288,6 +288,12 @@ suite('Workbench - TerminalInstance', () => { { code: 1260, message: `The terminal process failed to launch: Windows cannot open this program because it has been prevented by a software restriction policy. For more information, open Event Viewer or contact your system Administrator.` } ); }); + test('should format conpty launch failure', () => { + deepStrictEqual( + parseExitResult({ message: 'A native exception occurred during launch (Cannot launch conpty). Winpty has been removed, see https://code.visualstudio.com/updates/v1_109#_removal-of-winpty-support for more details. You can also try enabling the `terminal.integrated.windowsUseConptyDll` setting.' }, {}, ProcessState.KilledDuringLaunch, undefined), + { code: undefined, message: `The terminal process failed to launch: A native exception occurred during launch (Cannot launch conpty). Winpty has been removed, see https://code.visualstudio.com/updates/v1_109#_removal-of-winpty-support for more details. You can also try enabling the \`terminal.integrated.windowsUseConptyDll\` setting..` } + ); + }); test('should format generic failures', () => { deepStrictEqual( parseExitResult({ code: 123, message: 'A native exception occurred during launch (Cannot create process, error code: 123)' }, {}, ProcessState.KilledDuringLaunch, undefined), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 8c70eb496af..fdda1dc2b01 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -19,12 +19,11 @@ import { ChatElicitationRequestPart } from '../../../../../chat/common/model/cha import { ChatModel } from '../../../../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../../../../chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../../../chat/common/constants.js'; -import { ChatMessageRole, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; +import { ChatMessageRole, getTextResponseFromStream, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; import { IToolInvocationContext } from '../../../../../chat/common/tools/languageModelToolsService.js'; import { ITaskService } from '../../../../../tasks/common/taskService.js'; import { ILinkLocation } from '../../taskHelpers.js'; import { IConfirmationPrompt, IExecution, IPollingResult, OutputMonitorState, PollingConsts } from './types.js'; -import { getTextResponseFromStream } from './utils.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js'; import { ITerminalService } from '../../../../../terminal/browser/terminal.js'; @@ -470,9 +469,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { ); try { - const responseFromStream = getTextResponseFromStream(response); - await Promise.all([response.result, responseFromStream]); - return await responseFromStream; + return await getTextResponseFromStream(response); } catch (err) { return 'Error occurred ' + err; } @@ -877,13 +874,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private async _getLanguageModel(): Promise { - let models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); - - // Fallback to gpt-4o-mini if copilot-fast is not available for backwards compatibility - if (!models.length) { - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' }); - } - + const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); return models.length ? models[0] : undefined; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts index 3a22b30f9fa..0ec550b0961 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts @@ -3,31 +3,4 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ILanguageModelChatResponse } from '../../../../../chat/common/languageModels.js'; - -export async function getTextResponseFromStream(response: ILanguageModelChatResponse): Promise { - let responseText = ''; - const streaming = (async () => { - if (!response || !response.stream) { - return; - } - for await (const part of response.stream) { - if (Array.isArray(part)) { - for (const p of part) { - if (p.type === 'text') { - responseText += p.value; - } - } - } else if (part.type === 'text') { - responseText += part.value; - } - } - })(); - - try { - await Promise.all([response.result, streaming]); - return responseText; - } catch (err) { - return 'Error occurred ' + err; - } -} +export { getTextResponseFromStream } from '../../../../../chat/common/languageModels.js'; diff --git a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts index 7d207e48c41..550329a85ef 100644 --- a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts +++ b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @@ -558,13 +558,12 @@ export class ReleaseNotesManager extends Disposable { font-size: var(--vscode-font-size); font-family: var(--vscode-font-family); white-space: nowrap; - box-shadow: 1px 1px 1px rgba(0,0,0,.25); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); z-index: 100; overflow: hidden; display: flex; align-items: center; justify-content: center; - transition: border-radius 0.25s ease, padding 0.25s ease, width 0.25s ease; } #update-action-btn .icon { @@ -587,16 +586,18 @@ export class ReleaseNotesManager extends Disposable { max-width: 0; opacity: 0; margin-left: 0; - transition: max-width 0.25s ease, opacity 0.2s ease, margin-left 0.25s ease; } #update-action-btn:hover, #update-action-btn.expanded { background-color: var(--vscode-button-hoverBackground); - box-shadow: 2px 2px 2px rgba(0,0,0,.25); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); width: auto; - border-radius: 20px; - padding: 0 14px; + height: auto; + max-height: 40px; + border-radius: var(--vscode-cornerRadius-small); + padding: 6px 10px; + line-height: 16px; } #update-action-btn:hover .label, @@ -608,6 +609,7 @@ export class ReleaseNotesManager extends Disposable { #update-action-btn.expanded { background-color: var(--vscode-button-background); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); } body.vscode-high-contrast #update-action-btn { diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index 6801e3c8237..34b84089d18 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -47,6 +47,7 @@ import { ChatSessionPosition, getResourceForNewChatSession } from '../../chat/br import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { AgentSessionsControl, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionsListDelegate } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IResolvedWalkthrough, IWalkthroughsService } from '../../welcomeGettingStarted/browser/gettingStartedService.js'; import { GettingStartedEditorOptions, GettingStartedInput } from '../../welcomeGettingStarted/browser/gettingStartedInput.js'; @@ -716,7 +717,7 @@ export class AgentSessionsWelcomePage extends EditorPane { // Content const content = append(tosCard, $('.agentSessionsWelcome-walkthroughCard-content')); const title = append(content, $('.agentSessionsWelcome-walkthroughCard-title')); - title.textContent = localize('tosTitle', "Your GitHub Copilot trial is active"); + title.textContent = localize('tosTitle', "Try GitHub Copilot for free, no sign-in required!"); const desc = append(content, $('.agentSessionsWelcome-walkthroughCard-description')); const descriptionMarkdown = new MarkdownString( @@ -815,19 +816,19 @@ export class AgentSessionsWelcomePage extends EditorPane { // TODO: @osortega this is a weird way of doing this, maybe we handle the 2-colum layout in the control itself? const sessionsWidth = Math.min(800, this.lastDimension.width - 80); // Calculate height based on actual visible sessions (capped at MAX_SESSIONS) - // Use 54px per item from AgentSessionsListDelegate.ITEM_HEIGHT + // Use ITEM_HEIGHT per item from AgentSessionsListDelegate // Give the list FULL height so virtualization renders all items // CSS transforms handle the 2-column visual layout const visibleSessions = Math.min( this.agentSessionsService.model.sessions.filter(s => !s.isArchived()).length, MAX_SESSIONS ); - const sessionsHeight = visibleSessions * 56; + const sessionsHeight = visibleSessions * AgentSessionsListDelegate.ITEM_HEIGHT; this.sessionsControl.layout(sessionsHeight, sessionsWidth); // Set margin offset for 2-column layout: actual height - visual height - // Visual height = ceil(n/2) * 52, so offset = floor(n/2) * 52 - const marginOffset = Math.floor(visibleSessions / 2) * 52; + // Visual height = ceil(n/2) * ITEM_HEIGHT, so offset = floor(n/2) * ITEM_HEIGHT + const marginOffset = Math.floor(visibleSessions / 2) * AgentSessionsListDelegate.ITEM_HEIGHT; this.sessionsControl.element!.style.marginBottom = `-${marginOffset}px`; } diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css index 3021512101f..edc3e1d193d 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css @@ -168,31 +168,31 @@ * Each pair forms a visual row. * Left column items need to move up by floor((index-1)/2) rows * Right column items need to move right and up by (index/2) rows - * Row height is 52px. + * Row height is 44px. */ /* Left column items (odd positions): move up to form 2-column layout */ /* Item 3: move up 1 row */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(3) { - transform: translateY(-52px); + transform: translateY(-44px); } /* Item 5: move up 2 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(5) { - transform: translateY(-104px); + transform: translateY(-88px); } /* Right column items (even positions): move right and up */ /* Item 2: move right, up 1 row */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(2) { - transform: translateX(100%) translateY(-52px); + transform: translateX(100%) translateY(-44px); } /* Item 4: move right, up 2 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(4) { - transform: translateX(100%) translateY(-104px); + transform: translateX(100%) translateY(-88px); } /* Item 6: move right, up 3 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(6) { - transform: translateX(100%) translateY(-156px); + transform: translateX(100%) translateY(-132px); } /* Style individual session items in the welcome page */ @@ -482,7 +482,7 @@ gap: 12px; padding: 8px 12px 8px 8px; border-radius: 4px; - height: 52px; + height: 44px; box-sizing: border-box; } diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index d7a24923b9e..d782d8df7ba 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -20,6 +20,24 @@ import { DeepPartial } from '../../../../base/common/types.js'; export const IEditorGroupsService = createDecorator('editorGroupsService'); +export const enum GroupActivationReason { + + /** + * Group was activated explicitly by user or programmatic action. + */ + DEFAULT = 0, + + /** + * Group was activated because a modal or auxiliary editor part was closing. + */ + PART_CLOSE = 1 +} + +export interface IEditorGroupActivationEvent { + readonly group: IEditorGroup; + readonly reason: GroupActivationReason; +} + export const enum GroupDirection { UP, DOWN, @@ -212,7 +230,7 @@ export interface IEditorGroupsContainer { /** * An event for when a group gets activated. */ - readonly onDidActivateGroup: Event; + readonly onDidActivateGroup: Event; /** * An event for when the index of a group changes. diff --git a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts index 8c44dd65409..3a42f865c96 100644 --- a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { workbenchInstantiationService, registerTestEditor, TestFileEditorInput, TestEditorPart, TestServiceAccessor, ITestInstantiationService, workbenchTeardown, createEditorParts, TestEditorParts } from '../../../../test/browser/workbenchTestServices.js'; -import { GroupDirection, GroupsOrder, MergeGroupMode, GroupOrientation, GroupLocation, isEditorGroup, IEditorGroupsService, GroupsArrangement, IEditorGroupContextKeyProvider } from '../../common/editorGroupsService.js'; +import { GroupDirection, GroupsOrder, MergeGroupMode, GroupOrientation, GroupLocation, isEditorGroup, IEditorGroupsService, GroupsArrangement, IEditorGroupContextKeyProvider, GroupActivationReason, IEditorGroupActivationEvent } from '../../common/editorGroupsService.js'; import { CloseDirection, IEditorPartOptions, EditorsOrder, EditorInputCapabilities, GroupModelChangeKind, SideBySideEditor, IEditorFactoryRegistry, EditorExtensions } from '../../../../common/editor.js'; import { URI } from '../../../../../base/common/uri.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; @@ -2197,5 +2197,36 @@ suite('EditorGroupsService', () => { disposables.dispose(); }); + test('onDidActivateGroup carries activation reason', async function () { + const [part] = await createPart(); + + const activationEvents: IEditorGroupActivationEvent[] = []; + disposables.add(part.onDidActivateGroup(e => activationEvents.push(e))); + + const rootGroup = part.groups[0]; + const rightGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); + + // Activate a group explicitly - should carry DEFAULT reason + activationEvents.length = 0; + part.activateGroup(rightGroup); + assert.strictEqual(activationEvents.length, 1); + assert.strictEqual(activationEvents[0].group, rightGroup); + assert.strictEqual(activationEvents[0].reason, GroupActivationReason.DEFAULT); + + // Activate the same group again - should still fire with DEFAULT reason + activationEvents.length = 0; + part.activateGroup(rightGroup); + assert.strictEqual(activationEvents.length, 1); + assert.strictEqual(activationEvents[0].group, rightGroup); + assert.strictEqual(activationEvents[0].reason, GroupActivationReason.DEFAULT); + + // Activate root group back + activationEvents.length = 0; + part.activateGroup(rootGroup); + assert.strictEqual(activationEvents.length, 1); + assert.strictEqual(activationEvents[0].group, rootGroup); + assert.strictEqual(activationEvents[0].reason, GroupActivationReason.DEFAULT); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index ba91f4f6ac8..4d70e2e4a4b 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -149,7 +149,7 @@ import { CodeEditorService } from '../../services/editor/browser/codeEditorServi import { EditorPaneService } from '../../services/editor/browser/editorPaneService.js'; import { EditorResolverService } from '../../services/editor/browser/editorResolverService.js'; import { CustomEditorLabelService, ICustomEditorLabelService } from '../../services/editor/common/customEditorLabelService.js'; -import { EditorGroupLayout, GroupDirection, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, ICloseAllEditorsOptions, ICloseEditorOptions, ICloseEditorsFilter, IEditorDropTargetDelegate, IEditorGroup, IEditorGroupContextKeyProvider, IEditorGroupsContainer, IEditorGroupsService, IEditorPart, IEditorReplacement, IEditorWorkingSet, IEditorWorkingSetOptions, IFindGroupScope, IMergeGroupOptions, IModalEditorPart } from '../../services/editor/common/editorGroupsService.js'; +import { EditorGroupLayout, GroupDirection, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, ICloseAllEditorsOptions, ICloseEditorOptions, ICloseEditorsFilter, IEditorDropTargetDelegate, IEditorGroup, IEditorGroupActivationEvent, IEditorGroupContextKeyProvider, IEditorGroupsContainer, IEditorGroupsService, IEditorPart, IEditorReplacement, IEditorWorkingSet, IEditorWorkingSetOptions, IFindGroupScope, IMergeGroupOptions, IModalEditorPart } from '../../services/editor/common/editorGroupsService.js'; import { IEditorPaneService } from '../../services/editor/common/editorPaneService.js'; import { IEditorResolverService } from '../../services/editor/common/editorResolverService.js'; import { IEditorsChangeEvent, IEditorService, IRevertAllEditorsOptions, ISaveEditorsOptions, ISaveEditorsResult, PreferredGroup } from '../../services/editor/common/editorService.js'; @@ -863,7 +863,7 @@ export class TestEditorGroupsService implements IEditorGroupsService { readonly onDidCreateAuxiliaryEditorPart: Event = Event.None; readonly onDidChangeActiveGroup: Event = Event.None; - readonly onDidActivateGroup: Event = Event.None; + readonly onDidActivateGroup: Event = Event.None; readonly onDidAddGroup: Event = Event.None; readonly onDidRemoveGroup: Event = Event.None; readonly onDidMoveGroup: Event = Event.None; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index df078abc002..c3641a37061 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -102,7 +102,7 @@ declare module 'vscode' { * * This is also called on first load to get the initial set of items. */ - refreshHandler: (token: CancellationToken) => Thenable; + readonly refreshHandler: (token: CancellationToken) => Thenable; /** * Fired when an item's archived state changes. @@ -204,33 +204,33 @@ declare module 'vscode' { /** * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. */ - created: number; + readonly 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; + readonly 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; + readonly lastRequestEnded?: number; /** * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * @deprecated Use `created` and `lastRequestStarted` instead. */ - startTime?: number; + readonly startTime?: number; /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * @deprecated Use `lastRequestEnded` instead. */ - endTime?: number; + readonly endTime?: number; }; /** diff --git a/test/smoke/src/areas/terminal/terminal-persistence.test.ts b/test/smoke/src/areas/terminal/terminal-persistence.test.ts index 455d1f016d4..c4cb0ca0fd0 100644 --- a/test/smoke/src/areas/terminal/terminal-persistence.test.ts +++ b/test/smoke/src/areas/terminal/terminal-persistence.test.ts @@ -16,7 +16,10 @@ export function setup(options?: { skipSuite: boolean }) { const app = this.app as Application; terminal = app.workbench.terminal; settingsEditor = app.workbench.settingsEditor; - await setTerminalTestSettings(app); + await setTerminalTestSettings(app, [ + // Use ${process} for terminal title to ensure stable names for detach/attach + ['terminal.integrated.tabs.title', '"${process}"'] + ]); }); after(async function () {