diff --git a/.vscode/settings.json b/.vscode/settings.json index ec8c556838b26..da775f21244f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,11 +24,9 @@ "files.insertFinalNewline": false }, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true }, "[javascript]": { - "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true }, "[rust]": { diff --git a/build/azure-pipelines/product-npm-package-validate.yml b/build/azure-pipelines/product-npm-package-validate.yml index b256107437d49..37483396b23e8 100644 --- a/build/azure-pipelines/product-npm-package-validate.yml +++ b/build/azure-pipelines/product-npm-package-validate.yml @@ -36,7 +36,7 @@ jobs: echo "$CHANGED_FILES" # Check if package.json or package-lock.json are in the changed files - if echo "$CHANGED_FILES" | grep -E '^(package\.json|package-lock\.json)$'; then + if echo "$CHANGED_FILES" | grep -E '(^|/)package(-lock)?\.json$'; then echo "##vso[task.setvariable variable=SHOULD_VALIDATE]true" echo "Package files were modified, proceeding with validation" else diff --git a/build/win32/code.iss b/build/win32/code.iss index 101dc2d3548f9..197ff3e9e2606 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1603,7 +1603,7 @@ begin begin #ifdef AppxPackageName // Remove the old context menu registry keys - if IsWindows11OrLater() and WizardIsTaskSelected('addcontextmenufiles') then begin + if IsWindows11OrLater() 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/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index 42656ea4bae20..abb2975f7de83 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -29,9 +29,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/html-language-features/package-lock.json b/extensions/html-language-features/package-lock.json index 442b79121ebb7..ab0ef3f9510e2 100644 --- a/extensions/html-language-features/package-lock.json +++ b/extensions/html-language-features/package-lock.json @@ -30,9 +30,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/json-language-features/package-lock.json b/extensions/json-language-features/package-lock.json index c7c66f40ccc77..57df1eea33719 100644 --- a/extensions/json-language-features/package-lock.json +++ b/extensions/json-language-features/package-lock.json @@ -30,9 +30,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index fb34d5324985d..95730ae8713fa 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -540,6 +540,84 @@ "settings": { "foreground": "#A8CAAD" } + }, + { + "name": "Markup Heading", + "scope": "markup.heading", + "settings": { + "foreground": "#64b0df", + "fontStyle": "bold" + } + }, + { + "name": "Markup Bold", + "scope": "markup.bold", + "settings": { + "foreground": "#C48081", + "fontStyle": "bold" + } + }, + { + "name": "Markup Italic", + "scope": "markup.italic", + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "Markup Strikethrough", + "scope": "markup.strikethrough", + "settings": { + "fontStyle": "strikethrough" + } + }, + { + "name": "Markup Underline", + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#C184C6" + } + }, + { + "name": "Markup List", + "scope": "markup.list", + "settings": { + "foreground": "#48C9C4" + } + }, + { + "name": "Markup Inline Raw", + "scope": "markup.inline.raw", + "settings": { + "foreground": "#D1D6AE" + } + }, + { + "name": "Markup Raw/Fenced Code Block", + "scope": [ + "markup.raw", + "markup.fenced_code" + ], + "settings": { + "foreground": "#888888" + } + }, + { + "name": "Markup Link", + "scope": [ + "meta.link", + "markup.underline.link" + ], + "settings": { + "foreground": "#48A0C7" + } } ], "semanticHighlighting": true, diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index f27220f626b33..0e34cf29aa2c6 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -546,6 +546,84 @@ "settings": { "foreground": "#2B9A69" } + }, + { + "name": "Markup Heading", + "scope": "markup.heading", + "settings": { + "foreground": "#5460C1", + "fontStyle": "bold" + } + }, + { + "name": "Markup Bold", + "scope": "markup.bold", + "settings": { + "foreground": "#B86855", + "fontStyle": "bold" + } + }, + { + "name": "Markup Italic", + "scope": "markup.italic", + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "Markup Strikethrough", + "scope": "markup.strikethrough", + "settings": { + "fontStyle": "strikethrough" + } + }, + { + "name": "Markup Underline", + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#8F41AD" + } + }, + { + "name": "Markup List", + "scope": "markup.list", + "settings": { + "foreground": "#46969A" + } + }, + { + "name": "Markup Inline Raw", + "scope": "markup.inline.raw", + "settings": { + "foreground": "#98863B" + } + }, + { + "name": "Markup Raw/Fenced Code Block", + "scope": [ + "markup.raw", + "markup.fenced_code" + ], + "settings": { + "foreground": "#666666" + } + }, + { + "name": "Markup Link", + "scope": [ + "meta.link", + "markup.underline.link" + ], + "settings": { + "foreground": "#0069CC" + } } ], "semanticHighlighting": true, diff --git a/package.json b/package.json index 913cad90dbfe4..b7034d2d07b97 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "6a427d4e06fa83b1b299fde50735094cb6562065", + "distro": "1068d56a15875d7056ef59cfe1df4476138574fd", "author": { "name": "Microsoft Corporation" }, diff --git a/src/tsconfig.monaco.json b/src/tsconfig.monaco.json index 6293f59ba2b6e..0fbd224079942 100644 --- a/src/tsconfig.monaco.json +++ b/src/tsconfig.monaco.json @@ -11,7 +11,7 @@ "moduleResolution": "nodenext", "removeComments": false, "preserveConstEnums": true, - "target": "ES2022", + "target": "ES2024", "sourceMap": false, "declaration": true, "skipLibCheck": true diff --git a/src/tsconfig.vscode-dts.json b/src/tsconfig.vscode-dts.json index 3df2c2292ef3e..b83f686e4f3d3 100644 --- a/src/tsconfig.vscode-dts.json +++ b/src/tsconfig.vscode-dts.json @@ -13,7 +13,7 @@ "forceConsistentCasingInFileNames": true, "types": [], "lib": [ - "ES2022" + "ES2024" ], }, "include": [ diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index 5b32ddc9d8522..8b7fc2d9b33cc 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -351,10 +351,10 @@ export class Button extends Disposable implements IButton { set checked(value: boolean) { if (value) { this._element.classList.add('checked'); - this._element.setAttribute('aria-checked', 'true'); + this._element.setAttribute('aria-pressed', 'true'); } else { this._element.classList.remove('checked'); - this._element.setAttribute('aria-checked', 'false'); + this._element.setAttribute('aria-pressed', 'false'); } } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 1901394a710b3..b81e7b5e87849 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -251,6 +251,7 @@ export class MenuId { static readonly ChatWelcomeContext = new MenuId('ChatWelcomeContext'); static readonly ChatMessageFooter = new MenuId('ChatMessageFooter'); static readonly ChatExecute = new MenuId('ChatExecute'); + static readonly ChatExecuteQueue = new MenuId('ChatExecuteQueue'); static readonly ChatInput = new MenuId('ChatInput'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly ChatModePicker = new MenuId('ChatModePicker'); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 5e76962350b19..df136bd178e0d 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,6 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; +import { URI } from '../../../base/common/uri.js'; export interface IBrowserViewBounds { windowId: number; @@ -83,10 +84,16 @@ export interface IBrowserViewFaviconChangeEvent { favicon: string; } +export enum BrowserNewPageLocation { + Foreground = 'foreground', + Background = 'background', + NewWindow = 'newWindow' +} export interface IBrowserViewNewPageRequest { - url: string; - name?: string; - background: boolean; + resource: URI; + location: BrowserNewPageLocation; + // Only applicable if location is NewWindow + position?: { x?: number; y?: number; width?: number; height?: number }; } export interface IBrowserViewFindInPageOptions { diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 33717cb54c213..ab70aa81dea4c 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,13 +7,14 @@ import { WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation } from '../common/browserView.js'; import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { isMacintosh } from '../../../base/common/platform.js'; +import { BrowserViewUri } from '../common/browserViewUri.js'; /** Key combinations that are used in system-level shortcuts. */ const nativeShortcuts = new Set([ @@ -38,6 +39,7 @@ export class BrowserView extends Disposable { private _lastScreenshot: VSBuffer | undefined = undefined; private _lastFavicon: string | undefined = undefined; private _lastError: IBrowserViewLoadError | undefined = undefined; + private _lastUserGestureTimestamp: number = -Infinity; private _window: IBaseWindow | undefined; private _isSendingKeyEvent = false; @@ -76,14 +78,19 @@ export class BrowserView extends Disposable { readonly onDidClose: Event = this._onDidClose.event; constructor( + public readonly id: string, private readonly viewSession: Electron.Session, private readonly storageScope: BrowserViewStorageScope, + createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView, + options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService ) { super(); const webPreferences: Electron.WebPreferences & { type: ReturnType } = { + ...options?.webPreferences, + nodeIntegration: false, contextIsolation: true, sandbox: true, @@ -94,22 +101,45 @@ export class BrowserView extends Disposable { type: 'browserView' }; - this._view = new WebContentsView({ webPreferences }); + this._view = new WebContentsView({ + webPreferences, + // Passing an `undefined` webContents triggers an error in Electron. + ...(options?.webContents ? { webContents: options.webContents } : {}) + }); this._view.setBackgroundColor('#FFFFFF'); this._view.webContents.setWindowOpenHandler((details) => { - // For new tab requests, fire event for workbench to handle - if (details.disposition === 'background-tab' || details.disposition === 'foreground-tab') { - this._onDidRequestNewPage.fire({ - url: details.url, - name: details.frameName || undefined, - background: details.disposition === 'background-tab' - }); - return { action: 'deny' }; // Deny the default browser behavior since we're handling it + const location = (() => { + switch (details.disposition) { + case 'background-tab': return BrowserNewPageLocation.Background; + case 'foreground-tab': return BrowserNewPageLocation.Foreground; + case 'new-window': return BrowserNewPageLocation.NewWindow; + default: return undefined; + } + })(); + + if (!location || !this.consumePopupPermission(location)) { + // Eventually we may want to surface this. For now, just silently block it. + return { action: 'deny' }; } - // Deny other requests like new windows. - return { action: 'deny' }; + return { + action: 'allow', + createWindow: (options) => { + const childView = createChildView(options); + const resource = BrowserViewUri.forUrl(details.url, childView.id); + + // Fire event for the workbench to open this view + this._onDidRequestNewPage.fire({ + resource, + location, + position: { x: options.x, y: options.y, width: options.width, height: options.height } + }); + + // Return the webContents so Electron can complete the window.open() call + return childView.webContents; + } + }; }); this._view.webContents.on('destroyed', () => { @@ -250,6 +280,20 @@ export class BrowserView extends Disposable { } }); + // Track user gestures for popup blocking logic. + // Roughly based on https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation. + webContents.on('input-event', (_event, input) => { + switch (input.type) { + case 'rawKeyDown': + case 'keyDown': + case 'mouseDown': + case 'pointerDown': + case 'pointerUp': + case 'touchEnd': + this._lastUserGestureTimestamp = Date.now(); + } + }); + // For now, always prevent sites from blocking unload. // In the future we may want to show a dialog to ask the user, // with heavy restrictions regarding interaction and repeated prompts. @@ -268,6 +312,22 @@ export class BrowserView extends Disposable { }); } + private consumePopupPermission(location: BrowserNewPageLocation): boolean { + switch (location) { + case BrowserNewPageLocation.Foreground: + case BrowserNewPageLocation.Background: + return true; + case BrowserNewPageLocation.NewWindow: + // Each user gesture allows one popup window within 1 second + if (this._lastUserGestureTimestamp > Date.now() - 1000) { + this._lastUserGestureTimestamp = -Infinity; + return true; + } + + return false; + } + } + get webContents(): Electron.WebContents { return this._view.webContents; } diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index a462d108ca098..66f9d2bb825d7 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -78,6 +78,27 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa }); } + /** + * Create a child browser view (used by window.open handler) + */ + private createBrowserView(id: string, session: Electron.Session, scope: BrowserViewStorageScope, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + if (this.browserViews.has(id)) { + throw new Error(`Browser view with id ${id} already exists`); + } + + const view = this.instantiationService.createInstance( + BrowserView, + id, + session, + scope, + // Recursive factory for nested windows + (options) => this.createBrowserView(generateUuid(), session, scope, options), + options + ); + this.browserViews.set(id, view); + return view; + } + async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { if (this.browserViews.has(id)) { // Note: scope will be ignored if the view already exists. @@ -90,8 +111,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa this.configureSession(session); BrowserViewMainService.knownSessions.add(session); - const view = this.instantiationService.createInstance(BrowserView, session, resolvedScope); - this.browserViews.set(id, view); + const view = this.createBrowserView(id, session, resolvedScope); return view.getState(); } diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index 8bb3e5144d02d..e9595bd406a72 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -939,6 +939,9 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio if (checked.indexOf(extension) !== -1) { return []; } + if (areSameExtensions(extension.identifier, { id: this.productService.defaultChatAgent.extensionId })) { + return []; + } checked.push(extension); const extensionsPack = extension.manifest.extensionPack ? extension.manifest.extensionPack : []; if (extensionsPack.length) { diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 9772de6682895..40349546ec845 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -1157,8 +1157,23 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const runQuery = async (query: Query, token: CancellationToken) => { const { extensions, total } = await this.queryGalleryExtensions(query, { targetPlatform: CURRENT_TARGET_PLATFORM, compatible: false, includePreRelease: !!options.includePreRelease, productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date } }, extensionGalleryManifest, token); - extensions.forEach((e, index) => setTelemetry(e, ((query.pageNumber - 1) * query.pageSize) + index, options.source)); - return { extensions, total }; + + const result: IGalleryExtension[] = []; + let defaultChatAgentExtension: IGalleryExtension | undefined; + for (let index = 0; index < extensions.length; index++) { + const extension = extensions[index]; + setTelemetry(extension, ((query.pageNumber - 1) * query.pageSize) + index, options.source); + if (areSameExtensions(extension.identifier, { id: this.productService.defaultChatAgent.extensionId, })) { + defaultChatAgentExtension = extension; + } else { + result.push(extension); + } + } + if (defaultChatAgentExtension) { + result.push(defaultChatAgentExtension); + } + + return { extensions: result, total }; }; const { extensions, total } = await runQuery(query, token); const getPage = async (pageIndex: number, ct: CancellationToken) => { @@ -1976,6 +1991,16 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } } + deprecated[this.productService.defaultChatAgent.extensionId.toLowerCase()] = { + disallowInstall: true, + extension: { + id: this.productService.defaultChatAgent.chatExtensionId, + displayName: 'GitHub Copilot Chat', + autoMigrate: { storage: false, donotDisable: true }, + preRelease: this.productService.quality !== 'stable' + } + }; + return { malicious, deprecated, search, autoUpdate }; } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 865a17fe6a8a6..ab11f6bb9504c 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -339,7 +339,10 @@ export interface IDeprecationInfo { readonly extension?: { readonly id: string; readonly displayName: string; - readonly autoMigrate?: { readonly storage: boolean }; + readonly autoMigrate?: { + readonly storage: boolean; + readonly donotDisable?: boolean; + }; readonly preRelease?: boolean; }; readonly settings?: readonly string[]; diff --git a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts index 1e75ad85b3da6..1efac036ddd23 100644 --- a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts +++ b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts @@ -53,10 +53,10 @@ export async function migrateUnsupportedExtensions(extensionManagementService: I logService.info(`Uninstalled the unsupported extension '${unsupportedExtension.identifier.id}'`); let preReleaseExtension = installed.find(i => areSameExtensions(i.identifier, { id: preReleaseExtensionId })); - if (!preReleaseExtension || (!preReleaseExtension.isPreReleaseVersion && isUnsupportedExtensionEnabled)) { - preReleaseExtension = await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: true, isMachineScoped: unsupportedExtension.isMachineScoped, operation: InstallOperation.Migrate, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); + if (!preReleaseExtension || (preReleaseExtension.isPreReleaseVersion !== !!preRelease && isUnsupportedExtensionEnabled)) { + preReleaseExtension = await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: preRelease, isMachineScoped: unsupportedExtension.isMachineScoped, operation: InstallOperation.Migrate, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); logService.info(`Installed the pre-release extension '${preReleaseExtension.identifier.id}'`); - if (!isUnsupportedExtensionEnabled) { + if (!autoMigrate.donotDisable && !isUnsupportedExtensionEnabled) { await extensionEnablementService.disableExtension(preReleaseExtension.identifier); logService.info(`Disabled the pre-release extension '${preReleaseExtension.identifier.id}' because the unsupported extension '${unsupportedExtension.identifier.id}' is disabled`); } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 2be0167e40162..d2458d0af6400 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -623,7 +623,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return detector.provider.provideParticipantDetection( extRequest, - { history }, + { history, yieldRequested: false }, { participants: options.participants, location: typeConvert.ChatLocation.to(options.location) }, token ); @@ -731,7 +731,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS }; } - const chatContext: vscode.ChatContext = { history, chatSessionContext }; + const chatContext: vscode.ChatContext = { history, chatSessionContext, yieldRequested: request.yieldRequested ?? false }; const task = agent.invoke( extRequest, chatContext, @@ -865,7 +865,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const convertedHistory = await this.prepareHistoryTurns(agent.extension, agent.id, context); const ehResult = typeConvert.ChatAgentResult.to(result); - return (await agent.provideFollowups(ehResult, { history: convertedHistory }, token)) + return (await agent.provideFollowups(ehResult, { history: convertedHistory, yieldRequested: false }, token)) .filter(f => { // The followup must refer to a participant that exists from the same extension const isValid = !f.participant || Iterable.some( @@ -965,7 +965,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } const history = await this.prepareHistoryTurns(agent.extension, agent.id, { history: context }); - return await agent.provideTitle({ history }, token); + return await agent.provideTitle({ history, yieldRequested: false }, token); } async $provideChatSummary(handle: number, context: IChatAgentHistoryEntryDto[], token: CancellationToken): Promise { @@ -975,7 +975,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } const history = await this.prepareHistoryTurns(agent.extension, agent.id, { history: context }); - return await agent.provideSummary({ history }, token); + return await agent.provideSummary({ history, yieldRequested: false }, token); } } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 8ce6edc8ebcf5..e900c111d9eef 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -661,7 +661,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, await this.getModelForRequest(request, entry.sessionObj.extension), [], new Map(), entry.sessionObj.extension, this._logService); const stream = entry.sessionObj.getActiveRequestStream(request); - await entry.sessionObj.session.requestHandler(chatRequest, { history: history }, stream.apiObject, token); + await entry.sessionObj.session.requestHandler(chatRequest, { history, yieldRequested: false }, stream.apiObject, token); // TODO: do we need to dispose the stream object? return {}; diff --git a/src/vs/workbench/browser/parts/compositeBarActions.ts b/src/vs/workbench/browser/parts/compositeBarActions.ts index 337ec9a0d4e9e..fe7bce2f083de 100644 --- a/src/vs/workbench/browser/parts/compositeBarActions.ts +++ b/src/vs/workbench/browser/parts/compositeBarActions.ts @@ -237,11 +237,11 @@ export class CompositeBarActionViewItem extends BaseActionViewItem { this.container.classList.add('icon'); } + // Use 'tab' inside tablist, 'button' for popup items outside tablist + const role = this.options.isTabList || !this.options.hasPopup ? 'tab' : 'button'; + this.container.setAttribute('role', role); if (this.options.hasPopup) { - this.container.setAttribute('role', 'button'); this.container.setAttribute('aria-haspopup', 'true'); - } else { - this.container.setAttribute('role', 'tab'); } // Try hard to prevent keyboard only focus feedback when using mouse @@ -479,7 +479,7 @@ export class CompositeOverflowActivityActionViewItem extends CompositeBarActionV @IConfigurationService configurationService: IConfigurationService, @IKeybindingService keybindingService: IKeybindingService, ) { - super(action, { icon: true, colors, hasPopup: true, hoverOptions }, () => true, themeService, hoverService, configurationService, keybindingService); + super(action, { icon: true, colors, hasPopup: true, hoverOptions, isTabList: true }, () => true, themeService, hoverService, configurationService, keybindingService); } showMenu(): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 2d79adc5c8dfc..af25fe124da7f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -12,7 +12,7 @@ import { RawContextKey, IContextKey, IContextKeyService } from '../../../../plat import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from './browserEditorInput.js'; @@ -21,7 +21,7 @@ import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -45,6 +45,7 @@ import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; import { logBrowserOpen } from './browserViewTelemetry.js'; +import { URI } from '../../../../base/common/uri.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -342,7 +343,11 @@ export class BrowserEditor extends EditorPane { this.setBackgroundImage(this._model.screenshot); if (context.newInGroup) { - this.focusUrlInput(); + if (this._model.url) { + this._browserContainer.focus(); + } else { + this.focusUrlInput(); + } } // Start / stop screenshots when the model visibility changes @@ -378,18 +383,27 @@ export class BrowserEditor extends EditorPane { this._devToolsOpenContext.set(e.isDevToolsOpen); })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ url, name, background }) => { - logBrowserOpen(this.telemetryService, background ? 'browserLinkBackground' : 'browserLinkForeground'); + this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, location, position }) => { + logBrowserOpen(this.telemetryService, (() => { + switch (location) { + case BrowserNewPageLocation.Background: return 'browserLinkBackground'; + case BrowserNewPageLocation.Foreground: return 'browserLinkForeground'; + case BrowserNewPageLocation.NewWindow: return 'browserLinkNewWindow'; + } + })()); - // Open a new browser tab for the requested URL - const browserUri = BrowserViewUri.forUrl(url, name ? `${input.id}-${name}` : undefined); + const targetGroup = location === BrowserNewPageLocation.NewWindow ? AUX_WINDOW_GROUP : this.group; this.editorService.openEditor({ - resource: browserUri, + resource: URI.from(resource), options: { pinned: true, - inactive: background + inactive: location === BrowserNewPageLocation.Background, + auxiliary: { + bounds: position, + compact: true + } } - }, this.group); + }, targetGroup); })); this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index b17981d752b8c..201d199804bf3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -66,14 +66,14 @@ class NewTabAction extends Action2 { title: localize2('browser.newTabAction', "New Tab"), category: BrowserCategory, f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupTabs, order: 1, }, + // When already in a browser, Ctrl/Cmd + T opens a new tab keybinding: { - // When already in a browser, Ctrl/Cmd + T opens a new tab - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions primary: KeyMod.CtrlCmd | KeyCode.KeyT, } @@ -100,15 +100,14 @@ class GoBackAction extends Action2 { title: localize2('browser.goBackAction', 'Go Back'), category: BrowserCategory, icon: Codicon.arrowLeft, - f1: false, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_BACK), menu: { id: MenuId.BrowserNavigationToolbar, group: 'navigation', order: 1, }, - precondition: CONTEXT_BROWSER_CAN_GO_BACK, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over editor navigation primary: KeyMod.Alt | KeyCode.LeftArrow, secondary: [KeyCode.BrowserBack], @@ -133,16 +132,15 @@ class GoForwardAction extends Action2 { title: localize2('browser.goForwardAction', 'Go Forward'), category: BrowserCategory, icon: Codicon.arrowRight, - f1: false, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD), menu: { id: MenuId.BrowserNavigationToolbar, group: 'navigation', order: 2, when: CONTEXT_BROWSER_CAN_GO_FORWARD }, - precondition: CONTEXT_BROWSER_CAN_GO_FORWARD, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over editor navigation primary: KeyMod.Alt | KeyCode.RightArrow, secondary: [KeyCode.BrowserForward], @@ -167,7 +165,8 @@ class ReloadAction extends Action2 { title: localize2('browser.reloadAction', 'Reload'), category: BrowserCategory, icon: Codicon.refresh, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserNavigationToolbar, group: 'navigation', @@ -198,9 +197,9 @@ class FocusUrlInputAction extends Action2 { id: FocusUrlInputAction.ID, title: localize2('browser.focusUrlInputAction', 'Focus URL Input'), category: BrowserCategory, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyL, } @@ -222,9 +221,10 @@ class AddElementToChatAction extends Action2 { super({ id: AddElementToChatAction.ID, title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), + category: BrowserCategory, icon: Codicon.inspect, - f1: false, - precondition: enabled, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, enabled), toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, @@ -233,11 +233,10 @@ class AddElementToChatAction extends Action2 { when: enabled }, keybinding: [{ - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, }, { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE), + when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.Escape }] @@ -260,7 +259,8 @@ class ToggleDevToolsAction extends Action2 { title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), category: BrowserCategory, icon: Codicon.console, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), menu: { id: MenuId.BrowserActionsToolbar, @@ -268,7 +268,6 @@ class ToggleDevToolsAction extends Action2 { order: 5, }, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.F12 } @@ -291,7 +290,8 @@ class OpenInExternalBrowserAction extends Action2 { title: localize2('browser.openExternalAction', 'Open in External Browser'), category: BrowserCategory, icon: Codicon.linkExternal, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupPage, @@ -371,7 +371,7 @@ class ClearEphemeralBrowserStorageAction extends Action2 { category: BrowserCategory, icon: Codicon.clearAll, f1: true, - precondition: BROWSER_EDITOR_ACTIVE, + precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), menu: { id: MenuId.BrowserActionsToolbar, group: '3_settings', @@ -422,14 +422,14 @@ class ShowBrowserFindAction extends Action2 { id: ShowBrowserFindAction.ID, title: localize2('browser.showFindAction', 'Find in Page'), category: BrowserCategory, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupPage, order: 1, }, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.EditorContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyF } @@ -452,8 +452,8 @@ class HideBrowserFindAction extends Action2 { title: localize2('browser.hideFindAction', 'Close Find Widget'), category: BrowserCategory, f1: false, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), keybinding: { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), weight: KeybindingWeight.EditorContrib + 5, primary: KeyCode.Escape } @@ -477,12 +477,13 @@ class BrowserFindNextAction extends Action2 { title: localize2('browser.findNextAction', 'Find Next'), category: BrowserCategory, f1: false, + precondition: BROWSER_EDITOR_ACTIVE, keybinding: [{ - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED), + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, weight: KeybindingWeight.EditorContrib, primary: KeyCode.Enter }, { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, weight: KeybindingWeight.EditorContrib, primary: KeyCode.F3, mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } @@ -507,12 +508,13 @@ class BrowserFindPreviousAction extends Action2 { title: localize2('browser.findPreviousAction', 'Find Previous'), category: BrowserCategory, f1: false, + precondition: BROWSER_EDITOR_ACTIVE, keybinding: [{ - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED), + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, weight: KeybindingWeight.EditorContrib, primary: KeyMod.Shift | KeyCode.Enter }, { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, weight: KeybindingWeight.EditorContrib, primary: KeyMod.Shift | KeyCode.F3, mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts index b5792e924d035..3f6a4f848f3b5 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts @@ -20,10 +20,12 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet * opens in a new focused editor (e.g., links with target="_blank"). * - `'browserLinkBackground'`: opened when clicking a link inside the Integrated Browser that * opens in a new background editor (e.g., Ctrl/Cmd+click). + * - `'browserLinkNewWindow'`: opened when clicking a link inside the Integrated Browser that + * opens in a new window (e.g., Shift+click). * - `'copyToNewWindow'`: opened when the user copies a browser editor to a new window * via "Copy into New Window". */ -export type IntegratedBrowserOpenSource = 'commandWithoutUrl' | 'commandWithUrl' | 'newTabCommand' | 'localhostLinkOpener' | 'browserLinkForeground' | 'browserLinkBackground' | 'copyToNewWindow'; +export type IntegratedBrowserOpenSource = 'commandWithoutUrl' | 'commandWithUrl' | 'newTabCommand' | 'localhostLinkOpener' | 'browserLinkForeground' | 'browserLinkBackground' | 'browserLinkNewWindow' | 'copyToNewWindow'; type IntegratedBrowserOpenEvent = { source: IntegratedBrowserOpenSource; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index 35f0fbfdbd801..f5d589353f4e6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -32,7 +32,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { ChatModel } from '../../common/model/chatModel.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; -import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatSendResult, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { PROMPT_LANGUAGE_ID } from '../../common/promptSyntax/promptTypes.js'; @@ -289,15 +289,15 @@ export class CreateRemoteAgentJobAction { ); await chatService.removeRequest(sessionResource, addedRequest.id); - const requestData = await chatService.sendRequest(sessionResource, userPrompt, { + const sendResult = await chatService.sendRequest(sessionResource, userPrompt, { agentIdSilent: continuationTargetType, attachedContext: attachedContext.asArray(), userSelectedModelId: widget.input.currentLanguageModel, ...widget.getModeRequestOptions() }); - if (requestData) { - await widget.handleDelegationExitIfNeeded(defaultAgent, requestData.agent); + if (ChatSendResult.isSent(sendResult)) { + await widget.handleDelegationExitIfNeeded(defaultAgent, sendResult.data.agent); } } catch (e) { console.error('Error creating remote coding agent job', e); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts index fb757671e5ca5..e0647b11c4af6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts @@ -33,7 +33,7 @@ export function registerChatCopyActions() { run(accessor: ServicesAccessor, context?: ChatTreeItem) { const clipboardService = accessor.get(IClipboardService); const chatWidgetService = accessor.get(IChatWidgetService); - const widget = (context?.sessionResource && chatWidgetService.getWidgetBySessionResource(context.sessionResource)) || chatWidgetService.lastFocusedWidget; + const widget = ((isRequestVM(context) || isResponseVM(context)) && chatWidgetService.getWidgetBySessionResource(context.sessionResource)) || chatWidgetService.lastFocusedWidget; if (widget) { const viewModel = widget.viewModel; const sessionAsText = viewModel?.getItems() @@ -84,6 +84,10 @@ export function registerChatCopyActions() { return; } + if (!isRequestVM(item) && !isResponseVM(item)) { + return; + } + const text = stringifyItem(item, false); await clipboardService.writeText(text); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts new file mode 100644 index 0000000000000..c4f3d740741b7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatRequestQueueKind, IChatService } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { IChatWidgetService } from '../chat.js'; +import { CHAT_CATEGORY } from './chatActions.js'; + +const queueingEnabledCondition = ContextKeyExpr.equals(`config.${ChatConfiguration.RequestQueueingEnabled}`, true); + +export interface IChatRemovePendingRequestContext { + sessionResource: URI; + pendingRequestId: string; +} + +function isRemovePendingRequestContext(context: unknown): context is IChatRemovePendingRequestContext { + return !!context && + typeof context === 'object' && + 'sessionResource' in context && + 'pendingRequestId' in context && + URI.isUri((context as IChatRemovePendingRequestContext).sessionResource) && + typeof (context as IChatRemovePendingRequestContext).pendingRequestId === 'string'; +} + +export class ChatQueueMessageAction extends Action2 { + static readonly ID = 'workbench.action.chat.queueMessage'; + + constructor() { + super({ + id: ChatQueueMessageAction.ID, + title: localize2('chat.queueMessage', "Add to Queue"), + icon: Codicon.add, + f1: false, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.requestInProgress, + ChatContextKeys.inputHasText + ), + menu: [{ + id: MenuId.ChatExecuteQueue, + group: 'navigation', + order: 1, + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (!widget?.viewModel) { + return; + } + + const inputValue = widget.getInput(); + if (!inputValue.trim()) { + return; + } + + widget.acceptInput(undefined, { queue: ChatRequestQueueKind.Queued }); + } +} + +export class ChatSteerWithMessageAction extends Action2 { + static readonly ID = 'workbench.action.chat.steerWithMessage'; + + constructor() { + super({ + id: ChatSteerWithMessageAction.ID, + title: localize2('chat.steerWithMessage', "Steer with Message"), + icon: Codicon.arrowRight, + f1: false, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.requestInProgress, + ChatContextKeys.inputHasText + ), + menu: [{ + id: MenuId.ChatExecuteQueue, + group: 'navigation', + order: 2, + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (!widget?.viewModel) { + return; + } + + const inputValue = widget.getInput(); + if (!inputValue.trim()) { + return; + } + + widget.acceptInput(undefined, { queue: ChatRequestQueueKind.Steering }); + } +} + +export class ChatRemovePendingRequestAction extends Action2 { + static readonly ID = 'workbench.action.chat.removePendingRequest'; + + constructor() { + super({ + id: ChatRemovePendingRequestAction.ID, + title: localize2('chat.removePendingRequest', "Remove from Queue"), + icon: Codicon.close, + f1: false, + category: CHAT_CATEGORY, + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const chatService = accessor.get(IChatService); + const [context] = args; + if (!isRemovePendingRequestContext(context)) { + return; + } + + chatService.removePendingRequest(context.sessionResource, context.pendingRequestId); + } +} + +export function registerChatQueueActions(): void { + registerAction2(ChatQueueMessageAction); + registerAction2(ChatSteerWithMessageAction); + registerAction2(ChatRemovePendingRequestAction); + + // Register the queue submenu as a split button dropdown in the execute toolbar + // This shows "Add to Queue" / "Steer with Message" when a request is in progress and input has text + MenuRegistry.appendMenuItem(MenuId.ChatExecute, { + submenu: MenuId.ChatExecuteQueue, + title: localize2('chat.queueSubmenu', "Queue"), + icon: Codicon.listOrdered, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.requestInProgress, + ChatContextKeys.inputHasText + ), + group: 'navigation', + order: 3, + isSplitButton: { togglePrimaryAction: true } + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index f30cf4c47126b..f58192d8d3751 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -327,17 +327,8 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo break; } case AgentSessionSection.More: { - if (child.collapsed) { - let autoExpandMore = false; - if (this.sessionsListFindIsOpen) { - autoExpandMore = true; // always expand when find is open - } else if (this.options.filter.getExcludes().read && child.element.sessions.some(session => !session.isRead())) { - autoExpandMore = true; // expand when showing only unread and this section includes unread - } - - if (autoExpandMore) { - this.sessionsList.expand(child.element); - } + if (child.collapsed && this.sessionsListFindIsOpen) { + this.sessionsList.expand(child.element); // always expand when find is open } break; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index a206e4e20f517..209ea059bccc8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -22,7 +22,7 @@ export enum AgentSessionsGrouping { export interface IAgentSessionsFilterOptions extends Partial { - readonly filterMenuId: MenuId; + readonly filterMenuId?: MenuId; readonly limitResults?: () => number | undefined; notifyResults?(count: number): void; @@ -41,7 +41,7 @@ const DEFAULT_EXCLUDES: IAgentSessionsFilterExcludes = Object.freeze({ export class AgentSessionsFilter extends Disposable implements Required { - private readonly STORAGE_KEY: string; + private readonly STORAGE_KEY = `agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu`; private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; @@ -61,8 +61,6 @@ export class AgentSessionsFilter extends Disposable implements Required ({ id: provider, label: getAgentSessionProviderName(provider) @@ -143,10 +146,10 @@ export class AgentSessionsFilter extends Disposable implements Required this.resolve(provider))); + this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); - this._register(this.chatSessionsService.onDidChangeSessionItems(provider => this.resolve(provider))); + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.resolve(chatSessionType))); // State this._register(this.storageService.onWillSaveState(() => { @@ -725,7 +726,7 @@ class AgentSessionsCache { metadata: session.metadata } satisfies ISerializedAgentSession)); - this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, safeStringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE); } loadCachedSessions(): IInternalAgentSessionData[] { @@ -764,6 +765,7 @@ class AgentSessionsCache { insertions: change.insertions, deletions: change.deletions, })) : session.changes, + metadata: session.metadata, })); } catch { return []; // invalid data in storage, fallback to empty sessions list diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index f1e0637de08c4..9251a44e9fe93 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -16,6 +16,7 @@ import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js' import { IAgentSessionsService } from './agentSessionsService.js'; import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, getAgentSessionTime } from './agentSessions.js'; +import { AgentSessionsFilter } from './agentSessionsFilter.js'; interface ISessionPickItem extends IQuickPickItem { readonly session: IAgentSession; @@ -75,8 +76,9 @@ export class AgentSessionsPicker { async pickAgentSession(): Promise { const disposables = new DisposableStore(); const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); + const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); - picker.items = this.createPickerItems(); + picker.items = this.createPickerItems(filter); picker.canAcceptInBackground = true; picker.placeholder = localize('chatAgentPickerPlaceholder', "Search agent sessions by name"); @@ -116,7 +118,7 @@ export class AgentSessionsPicker { await this.agentSessionsService.model.resolve(session.providerType); this.pickAgentSession(); } else { - picker.items = this.createPickerItems(); + picker.items = this.createPickerItems(filter); } })); @@ -124,8 +126,10 @@ export class AgentSessionsPicker { picker.show(); } - private createPickerItems(): (ISessionPickItem | IQuickPickSeparator)[] { - const sessions = this.agentSessionsService.model.sessions.sort(this.sorter.compare.bind(this.sorter)); + private createPickerItems(filter: AgentSessionsFilter): (ISessionPickItem | IQuickPickSeparator)[] { + const sessions = this.agentSessionsService.model.sessions + .filter(session => !filter.exclude(session)) + .sort(this.sorter.compare.bind(this.sorter)); const items: (ISessionPickItem | IQuickPickSeparator)[] = []; const groupedSessions = groupAgentSessionsByDate(sessions); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts index 4ad06c8d0e1a9..7ab8f018143af 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts @@ -16,12 +16,14 @@ import { openSession } from './agentSessionsOpener.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; import { archiveButton, deleteButton, getSessionButtons, getSessionDescription, renameButton, unarchiveButton } from './agentSessionsPicker.js'; +import { AgentSessionsFilter } from './agentSessionsFilter.js'; export const AGENT_SESSIONS_QUICK_ACCESS_PREFIX = 'agent '; export class AgentSessionsQuickAccessProvider extends PickerQuickAccessProvider { private readonly sorter = new AgentSessionsSorter(); + private readonly filter: AgentSessionsFilter; constructor( @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @@ -34,12 +36,16 @@ export class AgentSessionsQuickAccessProvider extends PickerQuickAccessProvider< label: localize('noAgentSessionResults', "No matching agent sessions") } }); + + this.filter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, {})); } protected async _getPicks(filter: string): Promise<(IQuickPickSeparator | IPickerQuickAccessItem)[]> { const picks: Array = []; - const sessions = this.agentSessionsService.model.sessions.sort(this.sorter.compare.bind(this.sorter)); + const sessions = this.agentSessionsService.model.sessions + .filter(session => !this.filter.exclude(session)) + .sort(this.sorter.compare.bind(this.sorter)); const groupedSessions = groupAgentSessionsByDate(sessions); for (const group of groupedSessions.values()) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 8a8a1e1b915b5..a2c7037a62221 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -657,6 +657,10 @@ export class AgentSessionsDataSource implements IAsyncDataSource' | undefined; // Track if we arrived at current tab via shortcut key private _sendToAgentTimeout: ReturnType | undefined; private _sendButton: HTMLButtonElement | undefined; private _sendButtonLabel: HTMLSpanElement | undefined; @@ -158,6 +159,20 @@ export class UnifiedQuickAccess extends Disposable { if (this._isInternalValueChange) { return; } + + // Check if user removed the shortcut character (including when input is emptied) - switch back to Files + if (this._arrivedViaShortcut) { + const shortcut = this._arrivedViaShortcut; + if (!value.startsWith(shortcut)) { + const filesTab = this._tabs.find(t => t.id === 'files'); + if (filesTab && filesTab !== this._currentTab) { + this._arrivedViaShortcut = undefined; + this._switchTab(filesTab, picker, false); + return; + } + } + } + const matchingTab = this._detectTabFromValue(value); if (matchingTab && matchingTab !== this._currentTab) { this._switchTab(matchingTab, picker, true); @@ -185,10 +200,15 @@ export class UnifiedQuickAccess extends Disposable { (item as IQuickPickItem & { id?: string }).id !== SEND_TO_AGENT_ID ); - // Get the filter text - const filterText = this._currentTab - ? picker.value.substring(this._currentTab.prefix.length).trim() - : picker.value.trim(); + // Get the filter text (without prefix or shortcut character) + let filterText: string; + if (this._arrivedViaShortcut && picker.value.startsWith(this._arrivedViaShortcut)) { + filterText = picker.value.substring(1).trim(); + } else if (this._currentTab) { + filterText = picker.value.substring(this._currentTab.prefix.length).trim(); + } else { + filterText = picker.value.trim(); + } // Send to agent if: // 1. Send-to-agent item is explicitly selected, OR @@ -205,6 +225,7 @@ export class UnifiedQuickAccess extends Disposable { this._providerCts = undefined; this._currentPicker = undefined; this._currentTab = undefined; + this._arrivedViaShortcut = undefined; // Clear any pending timeout if (this._sendToAgentTimeout) { clearTimeout(this._sendToAgentTimeout); @@ -407,12 +428,17 @@ export class UnifiedQuickAccess extends Disposable { } /** - * Send the current message to a new agent session (strips prefix). + * Send the current message to a new agent session (strips prefix or shortcut character). */ private async _sendMessage(value: string): Promise { - // Strip any prefix from the value + // Strip any prefix or shortcut character from the value let message = value; - if (this._currentTab) { + + // First, strip shortcut character if we arrived via shortcut + if (this._arrivedViaShortcut && message.startsWith(this._arrivedViaShortcut)) { + message = message.substring(1).trim(); + } else if (this._currentTab) { + // Otherwise strip the normal prefix if (value.startsWith(this._currentTab.prefix)) { message = value.substring(this._currentTab.prefix.length).trim(); } @@ -446,10 +472,16 @@ export class UnifiedQuickAccess extends Disposable { return; } - // Get the filter text (without prefix) - const filterText = this._currentTab - ? picker.value.substring(this._currentTab.prefix.length).trim() - : picker.value.trim(); + // Get the filter text (without prefix or shortcut character) + let filterText: string; + if (this._arrivedViaShortcut && picker.value.startsWith(this._arrivedViaShortcut)) { + // Strip shortcut character + filterText = picker.value.substring(1).trim(); + } else if (this._currentTab) { + filterText = picker.value.substring(this._currentTab.prefix.length).trim(); + } else { + filterText = picker.value.trim(); + } // Use full input if filter text is empty but there's input (user typed without prefix) const fullInput = picker.value.trim(); @@ -529,16 +561,40 @@ export class UnifiedQuickAccess extends Disposable { // Update picker value (with flag to prevent recursive tab detection) this._isInternalValueChange = true; if (preserveFilterText && previousTab) { - // User typed a prefix - keep the filter text, just change prefix - const filterText = picker.value.substring(previousTab.prefix.length); - picker.value = tab.prefix + filterText; + // User typed a shortcut prefix - normalize the value to show just the shortcut character + const currentValue = picker.value; + + // Strip previous tab's prefix if present + let filterText = currentValue; + if (currentValue.startsWith(previousTab.prefix)) { + filterText = currentValue.substring(previousTab.prefix.length); + } + + // Handle shortcut transitions - ensure only one shortcut char is shown + if (this._arrivedViaShortcut === '<' && tab.id === 'agentSessions') { + // Strip any leading "<" chars and set just one + filterText = filterText.replace(/^<+/, ''); + picker.value = '<' + filterText; + } else if (this._arrivedViaShortcut === '>' && tab.id === 'commands') { + // Strip any leading ">" chars and set just one + filterText = filterText.replace(/^>+/, ''); + picker.value = '>' + filterText; + } else { + // Normal prefix-based switching + picker.value = tab.prefix + filterText; + } } else if (previousTab) { // User clicked tab - keep current text but strip old prefix (don't add new prefix) const currentValue = picker.value; if (currentValue.startsWith(previousTab.prefix)) { picker.value = currentValue.substring(previousTab.prefix.length); } - // else: keep current value as-is + // Also strip shortcut character if present + if (picker.value.startsWith('<') || picker.value.startsWith('>')) { + picker.value = picker.value.substring(1); + } + // Clear shortcut tracking when switching via click + this._arrivedViaShortcut = undefined; } // else: first tab activation, value already set this._isInternalValueChange = false; @@ -552,8 +608,27 @@ export class UnifiedQuickAccess extends Disposable { /** * Detect which tab matches the current value based on prefix. * Only switches away from current tab if user explicitly typed a different prefix. + * Supports shortcut keys: ">" for Commands, "<" for Sessions. */ private _detectTabFromValue(value: string): IUnifiedQuickAccessTab | undefined { + // Check for "<" shortcut to switch to Sessions (from Files or Commands) + if (value === '<' || value.startsWith('<')) { + const sessionsTab = this._tabs.find(t => t.id === 'agentSessions'); + if (sessionsTab && this._currentTab?.id !== 'agentSessions') { + this._arrivedViaShortcut = '<'; + return sessionsTab; + } + } + + // Check for ">" shortcut to switch to Commands (from Files or Sessions) + if (value === '>' || value.startsWith('>')) { + const commandsTab = this._tabs.find(t => t.id === 'commands'); + if (commandsTab && this._currentTab?.id !== 'commands') { + this._arrivedViaShortcut = '>'; + return commandsTab; + } + } + // Don't auto-switch if current tab matches (user is just typing) if (this._currentTab && value.startsWith(this._currentTab.prefix)) { return this._currentTab; @@ -596,9 +671,15 @@ export class UnifiedQuickAccess extends Disposable { const [provider] = this._getOrInstantiateProvider(tab.prefix); if (provider) { - // Configure filtering - strip the tab's prefix from the filter value + // Configure filtering - strip the tab's prefix or shortcut character from the filter value const tabPrefix = tab.prefix; + const arrivedViaShortcut = this._arrivedViaShortcut; picker.filterValue = (value: string) => { + // If arrived via shortcut, strip the shortcut character + if (arrivedViaShortcut && value.startsWith(arrivedViaShortcut)) { + return value.substring(1); + } + // Otherwise strip the normal prefix if (value.startsWith(tabPrefix)) { return value.substring(tabPrefix.length); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index 985cc88e9593c..14bd1a005fce2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -52,8 +52,8 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess () => this._onDidChangeChatSessionItems.fire() )); - this._register(this.chatSessionsService.onDidChangeSessionItems(sessionType => { - if (sessionType === this.chatSessionType) { + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => { + if (chatSessionType === this.chatSessionType) { this._onDidChange.fire(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts index 8ef8c0356272f..d15972e2b3a7e 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts @@ -37,6 +37,8 @@ import { IAction, toAction } from '../../../../../base/common/actions.js'; import { WebviewInput } from '../../../webviewPanel/browser/webviewEditorInput.js'; import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../../platform/browserElements/common/browserElements.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { observableConfigValue, observableContextKey } from '../../../../../platform/observable/common/platformObservableUtils.js'; type BrowserType = 'simpleBrowser' | 'livePreview'; @@ -366,12 +368,9 @@ class SimpleBrowserOverlayController { @IInstantiationService instaService: IInstantiationService, @IConfigurationService private readonly configurationService: IConfigurationService, @IBrowserElementsService private readonly _browserElementsService: IBrowserElementsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { - if (!this.configurationService.getValue('chat.sendElementsToChat.enabled')) { - return; - } - this._domNode.classList.add('chat-simple-browser-overlay'); this._domNode.style.position = 'absolute'; this._domNode.style.bottom = `5px`; @@ -444,11 +443,18 @@ class SimpleBrowserOverlayController { return undefined; }); + // Observe chat enabled state and sendElementsToChat configuration + const chatEnabledObs = observableContextKey(ChatContextKeys.enabled.key, this.contextKeyService); + const sendElementsEnabledObs = observableConfigValue('chat.sendElementsToChat.enabled', true, this.configurationService); + this._store.add(autorun(r => { const activeEditor = activeIdObs.read(r); + const isChatEnabled = chatEnabledObs.read(r); + const isSendElementsEnabled = sendElementsEnabledObs.read(r); - if (!activeEditor) { + // Hide if chat is not enabled, sendElementsToChat is not enabled, or no active editor + if (!isChatEnabled || !isSendElementsEnabled || !activeEditor) { hide(); return; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d211a1d09296a..19d07a739aa01 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -78,6 +78,7 @@ import { registerLanguageModelActions } from './actions/chatLanguageModelActions import { registerMoveActions } from './actions/chatMoveActions.js'; import { registerNewChatActions } from './actions/chatNewActions.js'; import { registerChatPromptNavigationActions } from './actions/chatPromptNavigationActions.js'; +import { registerChatQueueActions } from './actions/chatQueueActions.js'; import { registerQuickChatActions } from './actions/chatQuickInputActions.js'; import { ChatAgentRecommendation } from './actions/chatAgentRecommendationActions.js'; import { registerChatTitleActions } from './actions/chatTitleActions.js'; @@ -597,6 +598,12 @@ configurationRegistry.registerConfiguration({ } } }, + [ChatConfiguration.RequestQueueingEnabled]: { + type: 'boolean', + description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), + default: false, + tags: ['experimental'], + }, [ChatConfiguration.EditModeHidden]: { type: 'boolean', description: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), @@ -1398,6 +1405,7 @@ registerChatFileTreeActions(); registerChatPromptNavigationActions(); registerChatTitleActions(); registerChatExecuteActions(); +registerChatQueueActions(); registerQuickChatActions(); registerChatExportActions(); registerMoveActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 860903d34937f..b96a8a4b80762 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -19,8 +19,8 @@ import { IChatResponseModel, IChatModelInputState } from '../common/model/chatMo import { IChatMode } from '../common/chatModes.js'; import { IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; import { CHAT_PROVIDER_ID } from '../common/participants/chatParticipantContribTypes.js'; -import { IChatElicitationRequest, IChatLocationData, IChatSendRequestOptions } from '../common/chatService/chatService.js'; -import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel } from '../common/model/chatViewModel.js'; +import { ChatRequestQueueKind, IChatElicitationRequest, IChatLocationData, IChatSendRequestOptions } from '../common/chatService/chatService.js'; +import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatPendingDividerViewModel } from '../common/model/chatViewModel.js'; import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; import { ChatAttachmentModel } from './attachments/chatAttachmentModel.js'; import { IChatEditorOptions } from './widgetHosts/editor/chatEditor.js'; @@ -207,7 +207,7 @@ export interface IChatFileTreeInfo { focus(): void; } -export type ChatTreeItem = IChatRequestViewModel | IChatResponseViewModel; +export type ChatTreeItem = IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel; export interface IChatListItemRendererOptions { readonly renderStyle?: 'compact' | 'minimal'; @@ -306,6 +306,11 @@ export interface IChatAcceptInputOptions { // box's current content is being accepted, or 'false' if a specific input // is being submitted to the widget. storeToHistory?: boolean; + /** + * When set, queues this message to be sent after the current request completes. + * If Steering, also sets yieldRequested on any active request to signal it should wrap up. + */ + queue?: ChatRequestQueueKind; } export interface IChatWidgetViewModelChangeEvent { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index d67e1a23322f6..a7a60c021a190 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -259,11 +259,11 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _alternativeIdMap: Map = new Map(); private readonly _contextKeys = new Set(); - private readonly _onDidChangeItemsProviders = this._register(new Emitter()); - readonly onDidChangeItemsProviders: Event = this._onDidChangeItemsProviders.event; + private readonly _onDidChangeItemsProviders = this._register(new Emitter<{ readonly chatSessionType: string }>()); + readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event; - private readonly _onDidChangeSessionItems = this._register(new Emitter()); - readonly onDidChangeSessionItems: Event = this._onDidChangeSessionItems.event; + private readonly _onDidChangeSessionItems = this._register(new Emitter<{ readonly chatSessionType: string }>()); + readonly onDidChangeSessionItems = this._onDidChangeSessionItems.event; private readonly _onDidChangeAvailability = this._register(new Emitter()); readonly onDidChangeAvailability: Event = this._onDidChangeAvailability.event; @@ -339,7 +339,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } })); - this._register(this.onDidChangeSessionItems(chatSessionType => { + this._register(this.onDidChangeSessionItems(({ chatSessionType }) => { this.updateInProgressStatus(chatSessionType).catch(error => { this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error); }); @@ -637,7 +637,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._onDidChangeItemsProviders.fire(provider); } for (const { contribution } of this._contributions.values()) { - this._onDidChangeSessionItems.fire(contribution.type); + this._onDidChangeSessionItems.fire({ chatSessionType: contribution.type }); } } this._updateHasCanDelegateProvidersContextKey(); @@ -730,7 +730,11 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._isContributionAvailable(contribution) ? contribution : undefined; } - async activateChatSessionItemProvider(chatViewType: string): Promise { + async activateChatSessionItemProvider(chatViewType: string): Promise { + await this.doActivateChatSessionItemProvider(chatViewType); + } + + private async doActivateChatSessionItemProvider(chatViewType: string): Promise { await this._extensionService.whenInstalledExtensionsRegistered(); const resolvedType = this._resolveToPrimaryType(chatViewType); if (resolvedType) { @@ -777,7 +781,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ continue; // skip: not considered for resolving } - const provider = await this.activateChatSessionItemProvider(contrib.type); + const provider = await this.doActivateChatSessionItemProvider(contrib.type); if (!provider) { // We requested this provider but it is not available if (providersToResolve?.includes(contrib.type)) { @@ -828,7 +832,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const disposables = new DisposableStore(); disposables.add(provider.onDidChangeChatSessionItems(() => { - this._onDidChangeSessionItems.fire(chatSessionType); + this._onDidChangeSessionItems.fire({ chatSessionType }); })); this.updateInProgressStatus(chatSessionType).catch(error => { @@ -1009,10 +1013,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return !!session?.setOption(optionId, value); } - public notifySessionItemsChanged(chatSessionType: string): void { - this._onDidChangeSessionItems.fire(chatSessionType); - } - /** * Store option groups for a session type */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts index 07ec74d925f3b..e7e7ba7992198 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts @@ -7,7 +7,7 @@ import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IChatProgressRenderableResponseContent } from '../../../common/model/chatModel.js'; -import { IChatConfirmation, IChatSendRequestOptions, IChatService } from '../../../common/chatService/chatService.js'; +import { ChatSendResult, IChatConfirmation, IChatSendRequestOptions, IChatService } from '../../../common/chatService/chatService.js'; import { isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatWidgetService } from '../../chat.js'; import { SimpleChatConfirmationWidget } from './chatConfirmationWidget.js'; @@ -54,7 +54,8 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont options.location = widget?.location; Object.assign(options, widget?.getModeRequestOptions()); - if (await this.chatService.sendRequest(element.sessionResource, prompt, options)) { + const result = await this.chatService.sendRequest(element.sessionResource, prompt, options); + if (ChatSendResult.isSent(result)) { confirmation.isUsed = true; confirmationWidget.setShowButtons(false); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts index 472f6e5c35ce6..e15b3d1fafd03 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts @@ -5,7 +5,7 @@ import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ChatTreeItem, IChatCodeBlockInfo } from '../../chat.js'; -import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; import { CodeBlockModelCollection } from '../../../common/widget/codeBlockModelCollection.js'; import { DiffEditorPool, EditorPool } from './chatContentCodePools.js'; import { IObservable } from '../../../../../../base/common/observable.js'; @@ -41,7 +41,7 @@ export interface IChatContentPart extends IDisposable { } export interface IChatContentPartRenderContext { - readonly element: ChatTreeItem; + readonly element: IChatRequestViewModel | IChatResponseViewModel; readonly elementIndex: number; readonly container: HTMLElement; readonly content: ReadonlyArray; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts index c58fdfc1206e0..d21b5f1af405c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts @@ -199,6 +199,7 @@ export class ChatTodoListWidget extends Disposable { private createClearButton(): void { this.clearButton = new Button(this.clearButtonContainer, { supportIcons: true, + ariaLabel: localize('chat.todoList.clearButton', 'Clear all todos'), }); this.clearButton.element.tabIndex = 0; this.clearButton.icon = Codicon.clearAll; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index a28f9beb0514c..53368319cefc6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -54,11 +54,11 @@ import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM } from '../../common/model/chatViewModel.js'; import { getNWords } from '../../common/model/chatWordCounter.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; @@ -632,9 +632,17 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('img.icon'); avatarIcon.src = FileAccess.uriToBrowserUri(icon).toString(true); @@ -923,6 +974,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - return element.dataId + + // Pending types only have 'id', request/response have 'dataId' + const baseId = (isRequestVM(element) || isResponseVM(element)) ? element.dataId : element.id; + const disablement = (isRequestVM(element) || isResponseVM(element)) ? element.shouldBeRemovedOnSend : undefined; + return baseId + // If a response is in the process of progressive rendering, we need to ensure that it will // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. `${isResponseVM(element) && element.renderData ? `_${this._visibleChangeCount}` : ''}` + // Re-render once content references are loaded (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + // Re-render if element becomes hidden due to undo/redo - `_${element.shouldBeRemovedOnSend ? `${element.shouldBeRemovedOnSend.afterUndoStop || '1'}` : '0'}` + + `_${disablement ? `${disablement.afterUndoStop || '1'}` : '0'}` + // Re-render if we have an element currently being edited `_${editing ? '1' : '0'}` + // Re-render if we have an element currently being checkpointed @@ -812,7 +815,7 @@ export class ChatListWidget extends Disposable { this._container.style.removeProperty('--chat-current-response-min-height'); } else { const secondToLastItem = this._viewModel?.getItems().at(-2); - const secondToLastItemHeight = Math.min(secondToLastItem?.currentRenderedHeight ?? 150, 150); + const secondToLastItemHeight = Math.min((isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem)) ? secondToLastItem.currentRenderedHeight ?? 150 : 150, 150); const lastItemMinHeight = Math.max(contentHeight - (secondToLastItemHeight + 10), 0); this._container.style.setProperty('--chat-current-response-min-height', lastItemMinHeight + 'px'); if (lastItemMinHeight !== this._previousLastItemMinHeight) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 56c7f2ed3713b..07aa3be77dca1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -54,7 +54,7 @@ import { IChatModel, IChatModelInputState, IChatResponseModel } from '../../comm import { ChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; -import { IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatSlashCommandService } from '../../common/participants/chatSlashCommands.js'; import { IChatTodoListService } from '../../common/tools/chatTodoListService.js'; @@ -237,7 +237,6 @@ export class ChatWidget extends Disposable implements IChatWidget { private visibleChangeCount = 0; private requestInProgress: IContextKey; private agentInInput: IContextKey; - private currentRequest: Promise | undefined; private _visible = false; get visible() { return this._visible; } @@ -288,7 +287,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.logService.debug('ChatWidget#setViewModel: no viewModel'); } - this.currentRequest = undefined; this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource }); } @@ -2057,9 +2055,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } } - private async _acceptInput(query: { query: string } | undefined, options?: IChatAcceptInputOptions): Promise { + private async _acceptInput(query: { query: string } | undefined, options: IChatAcceptInputOptions = {}): Promise { if (this.viewModel?.model.requestInProgress.get()) { - return; + options.queue ??= ChatRequestQueueKind.Queued; } if (!query && this.input.generating) { @@ -2140,12 +2138,6 @@ export class ChatWidget extends Disposable implements IChatWidget { }; this.telemetryService.publicLog2('chatEditing/workingSetSize', { originalSize: uniqueWorkingSetEntries.size, actualSize: uniqueWorkingSetEntries.size }); } - this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); - if (this.currentRequest) { - // We have to wait the current request to be properly cancelled so that it has a chance to update the model with its result metadata. - // This is awkward, it's basically a limitation of the chat provider-based agent. - await Promise.race([this.currentRequest, timeout(1000)]); - } this.input.validateAgentMode(); @@ -2158,7 +2150,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } } } - if (this.viewModel.sessionResource) { + if (this.viewModel.sessionResource && !options.queue) { + // todo@connor4312: move chatAccessibilityService.acceptRequest to a refcount model to handle queue messages this.chatAccessibilityService.acceptRequest(this._viewModel!.sessionResource); } @@ -2172,20 +2165,29 @@ export class ChatWidget extends Disposable implements IChatWidget { ...this.getModeRequestOptions(), modeInfo: this.input.currentModeInfo, agentIdSilent: this._lockedAgent?.id, + queue: options?.queue, }); - if (!result) { + if (this.viewModel.sessionResource && !options.queue) { this.chatAccessibilityService.disposeRequest(this.viewModel.sessionResource); + } + + if (ChatSendResult.isRejected(result)) { return; } - // visibility sync before we accept input to hide the welcome view + // visibility sync before firing events to hide the welcome view this.updateChatViewVisibility(); - this.input.acceptInput(options?.storeToHistory ?? isUserQuery); - this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand }); - this.handleDelegationExitIfNeeded(this._lockedAgent, result.agent); - this.currentRequest = result.responseCompletePromise.then(() => { + + const sent = ChatSendResult.isQueued(result) ? await result.deferred : result; + if (!ChatSendResult.isSent(sent)) { + return; + } + + this._onDidSubmitAgent.fire({ agent: sent.data.agent, slashCommand: sent.data.slashCommand }); + this.handleDelegationExitIfNeeded(this._lockedAgent, sent.data.agent); + sent.data.responseCompletePromise.then(() => { const responses = this.viewModel?.getItems().filter(isResponseVM); const lastResponse = responses?.[responses.length - 1]; this.chatAccessibilityService.acceptResponse(this, this.container, lastResponse, this.viewModel?.sessionResource, options?.isVoiceInput); @@ -2196,10 +2198,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this.input.setValue(question, false); } } - this.currentRequest = undefined; }); - return result.responseCreatedPromise; + return sent.data.responseCreatedPromise; } getModeRequestOptions(): Partial { @@ -2427,7 +2428,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are always enabled`); const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined; const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined; - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeObs.get(), enabledTools, enabledSubAgents); + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeKind, enabledTools, enabledSubAgents); await computer.collect(attachedContext, CancellationToken.None); } 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 d0831ab9c95d0..887cbf1bf027a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -263,6 +263,9 @@ color: var(--vscode-descriptionForeground); line-height: 16px; margin-left: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .interactive-item-container .chat-footer-details.hidden { @@ -787,6 +790,11 @@ have to be updated for changes to the rules above, or to support more deeply nes z-index: 1; } +/* Hide context usage widget in compact mode (quick chat) */ +.interactive-session .interactive-input-part.compact .chat-context-usage-container { + display: none; +} + .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container, .interactive-input-part:has(.chat-todo-list-widget-container > .chat-todo-list-widget.has-todos) .chat-input-container, .interactive-input-part:has(.chat-input-widgets-container > .chat-status-widget:not([style*="display: none"])) .chat-input-container { @@ -2847,3 +2855,34 @@ have to be updated for changes to the rules above, or to support more deeply nes font-style: italic; color: var(--vscode-descriptionForeground); } + +/* Pending request styles */ +.interactive-item-container.pending-request { + opacity: 0.7; +} + +.interactive-item-container .chat-request-status { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-xs); + margin-top: 2px; + text-align: right; +} + +/* Pending divider styles */ +.interactive-item-container.pending-divider { + padding: 8px 16px; +} + +.interactive-item-container.pending-divider .pending-divider-content { + display: flex; + align-items: center; + gap: 8px; +} + +.interactive-item-container.pending-divider .pending-divider-label { + font-size: var(--vscode-chat-font-size-body-xs); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 90858649e663d..2c31f62bdd0d4 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -69,6 +69,8 @@ export namespace ChatContextKeys { export const chatSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('chatSessionType', "The type of the current chat session.") }); export const hasFileAttachments = new RawContextKey('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); + export const hasPendingRequests = new RawContextKey('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") }); + export const pendingRequestCount = new RawContextKey('chatPendingRequestCount', 0, { type: 'number', description: localize('chatPendingRequestCount', "Number of pending requests in the queue.") }); export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index ed2b28d0265ab..08a63e50dbd12 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1154,6 +1154,54 @@ export interface IChatSendRequestData extends IChatSendRequestResponseState { slashCommand?: IChatAgentCommand; } +/** + * Result of a sendRequest call - a discriminated union of possible outcomes. + */ +export type ChatSendResult = + | ChatSendResultRejected + | ChatSendResultSent + | ChatSendResultQueued; + +export interface ChatSendResultRejected { + readonly kind: 'rejected'; + readonly reason: string; +} + +export interface ChatSendResultSent { + readonly kind: 'sent'; + readonly data: IChatSendRequestData; +} + +export interface ChatSendResultQueued { + readonly kind: 'queued'; + /** + * Promise that resolves when the queued message is actually processed. + * Will resolve to a 'sent' or 'rejected' result. + */ + readonly deferred: Promise; +} + +export namespace ChatSendResult { + export function isSent(result: ChatSendResult): result is ChatSendResultSent { + return result.kind === 'sent'; + } + + export function isRejected(result: ChatSendResult): result is ChatSendResultRejected { + return result.kind === 'rejected'; + } + + export function isQueued(result: ChatSendResult): result is ChatSendResultQueued { + return result.kind === 'queued'; + } + + /** Assertion function for tests - asserts that the result is a sent result */ + export function assertSent(result: ChatSendResult): asserts result is ChatSendResultSent { + if (result.kind !== 'sent') { + throw new Error(`Expected ChatSendResult to be 'sent', but was '${result.kind}'`); + } + } +} + export interface IChatEditorLocationData { type: ChatAgentLocation.EditorInline; id: string; @@ -1174,6 +1222,16 @@ export interface IChatTerminalLocationData { export type IChatLocationData = IChatEditorLocationData | IChatNotebookLocationData | IChatTerminalLocationData; +/** + * The kind of queue request. + */ +export const enum ChatRequestQueueKind { + /** Request is queued to be sent after current request completes */ + Queued = 'queued', + /** Request is queued and signals the active request to yield */ + Steering = 'steering' +} + export interface IChatSendRequestOptions { modeInfo?: IChatRequestModeInfo; userSelectedModelId?: string; @@ -1200,6 +1258,12 @@ export interface IChatSendRequestOptions { */ confirmation?: string; + /** + * When set, queues this message to be sent after the current request completes. + * If Steering, also sets yieldRequested on any active request to signal it should wrap up. + */ + queue?: ChatRequestQueueKind; + } export type IChatModelReference = IReference; @@ -1241,9 +1305,10 @@ export interface IChatService { getChatSessionFromInternalUri(sessionResource: URI): IChatSessionContext | undefined; /** - * Returns whether the request was accepted.` + * Sends a chat request for the given session. + * @returns A result indicating whether the request was sent, queued, or rejected. */ - sendRequest(sessionResource: URI, message: string, options?: IChatSendRequestOptions): Promise; + sendRequest(sessionResource: URI, message: string, options?: IChatSendRequestOptions): Promise; /** * Sets a custom title for a chat model. @@ -1254,6 +1319,19 @@ export interface IChatService { adoptRequest(sessionResource: URI, request: IChatRequestModel): Promise; removeRequest(sessionResource: URI, requestId: string): Promise; cancelCurrentRequestForSession(sessionResource: URI): void; + /** + * Sets yieldRequested on the active request for the given session. + */ + setYieldRequested(sessionResource: URI): void; + /** + * Removes a pending request from the session's queue. + */ + removePendingRequest(sessionResource: URI, requestId: string): void; + /** + * Sets the pending requests for a session, allowing for deletions/reordering. + * Adding new requests should go through sendRequest with the queue option. + */ + setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void; addCompleteRequest(sessionResource: URI, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void; setChatSessionTitle(sessionResource: URI, title: string): void; getLocalSessionHistory(): Promise; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index fb9131a929f68..2fe2f182f6728 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -37,7 +37,7 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; -import { ChatMcpServersStarting, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSessionsService } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; @@ -53,6 +53,12 @@ import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; const serializedChatKey = 'interactive.sessions'; class CancellableRequest implements IDisposable { + private _yieldRequested = false; + + get yieldRequested(): boolean { + return this._yieldRequested; + } + constructor( public readonly cancellationTokenSource: CancellationTokenSource, public requestId: string | undefined, @@ -70,6 +76,10 @@ class CancellableRequest implements IDisposable { this.cancellationTokenSource.cancel(); } + + setYieldRequested(): void { + this._yieldRequested = true; + } } export class ChatService extends Disposable implements IChatService { @@ -77,6 +87,7 @@ export class ChatService extends Disposable implements IChatService { private readonly _sessionModels: ChatModelStore; private readonly _pendingRequests = this._register(new DisposableResourceMap()); + private readonly _queuedRequestDeferreds = new Map>(); private _saveModelsEnabled = true; private _transferredSessionResource: URI | undefined; @@ -715,13 +726,13 @@ export class ChatService extends Disposable implements IChatService { await this._sendRequestAsync(model, model.sessionResource, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise; } - async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { + async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); if (!request.trim() && !options?.slashCommand && !options?.agentId && !options?.agentIdSilent) { this.trace('sendRequest', 'Rejected empty message'); - return; + return { kind: 'rejected', reason: 'Empty message' }; } const model = this._sessionModels.get(sessionResource); @@ -730,8 +741,38 @@ export class ChatService extends Disposable implements IChatService { } if (this._pendingRequests.has(sessionResource)) { + // A request is already in progress + if (options?.queue) { + // Queue this message to be sent after the current request completes + const location = options?.location ?? model.initialLocation; + const parsedRequest = this.parseChatRequest(sessionResource, request, location, options); + const requestModel = new ChatRequestModel({ + session: model as ChatModel, + message: parsedRequest, + variableData: { variables: [] }, + timestamp: Date.now(), + modeInfo: options?.modeInfo, + locationData: options?.locationData, + attachedContext: options?.attachedContext, + modelId: options?.userSelectedModelId, + userSelectedTools: options?.userSelectedTools?.get(), + }); + + // Create a deferred promise that will be resolved when this queued request is processed + const deferred = new DeferredPromise(); + this._queuedRequestDeferreds.set(requestModel.id, deferred); + + model.addPendingRequest(requestModel, options.queue, { ...options, queue: undefined }); + + if (options.queue === ChatRequestQueueKind.Steering) { + this.setYieldRequested(sessionResource); + } + + this.trace('sendRequest', `Queued message for session ${sessionResource}`); + return { kind: 'queued', deferred: deferred.p }; + } this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); - return; + return { kind: 'rejected', reason: 'Request already in progress' }; } const requests = model.getRequests(); @@ -757,9 +798,12 @@ export class ChatService extends Disposable implements IChatService { // This method is only returning whether the request was accepted - don't block on the actual request return { - ...this._sendRequestAsync(model, sessionResource, parsedRequest, attempt, !options?.noCommandDetection, silentAgent ?? defaultAgent, location, options), - agent, - slashCommand: agentSlashCommandPart?.command, + kind: 'sent', + data: { + ...this._sendRequestAsync(model, sessionResource, parsedRequest, attempt, !options?.noCommandDetection, silentAgent ?? defaultAgent, location, options), + agent, + slashCommand: agentSlashCommandPart?.command, + }, }; } @@ -1066,6 +1110,8 @@ export class ChatService extends Disposable implements IChatService { this._pendingRequests.set(model.sessionResource, this.instantiationService.createInstance(CancellableRequest, source, undefined)); rawResponsePromise.finally(() => { this._pendingRequests.deleteAndDispose(model.sessionResource); + // Process the next pending request from the queue if any + this.processNextPendingRequest(model); }); this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource }); return { @@ -1074,6 +1120,49 @@ export class ChatService extends Disposable implements IChatService { }; } + /** + * Process the next pending request from the model's queue, if any. + * Called after a request completes to continue processing queued requests. + */ + private processNextPendingRequest(model: ChatModel): void { + const pendingRequest = model.dequeuePendingRequest(); + if (!pendingRequest) { + return; + } + + this.trace('processNextPendingRequest', `Processing queued request for session ${model.sessionResource}`); + + const deferred = this._queuedRequestDeferreds.get(pendingRequest.id); + this._queuedRequestDeferreds.delete(pendingRequest.id); + + const sendOptions = pendingRequest.sendOptions; + const location = sendOptions.location ?? sendOptions.locationData?.type ?? model.initialLocation; + const defaultAgent = this.chatAgentService.getDefaultAgent(location, sendOptions.modeInfo?.kind); + if (!defaultAgent) { + this.logService.warn('processNextPendingRequest', `No default agent for location ${location}`); + deferred?.complete({ kind: 'rejected', reason: 'No default agent available' }); + return; + } + + const parsedRequest = pendingRequest.request.message; + const silentAgent = sendOptions.agentIdSilent ? this.chatAgentService.getAgent(sendOptions.agentIdSilent) : undefined; + const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; + const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + + // Send the queued request - this will add it to _pendingRequests and handle it normally + const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, pendingRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions); + + // Resolve the deferred with the sent result + deferred?.complete({ + kind: 'sent', + data: { + ...responseState, + agent, + slashCommand: agentSlashCommandPart?.command, + }, + }); + } + private generateInitialChatTitleIfNeeded(model: ChatModel, request: IChatAgentRequest, defaultAgent: IChatAgentData, token: CancellationToken): void { // Generate a title only for the first request, and only via the default agent. // Use a single-entry history based on the current request (no full chat history). @@ -1221,6 +1310,34 @@ export class ChatService extends Disposable implements IChatService { this._pendingRequests.deleteAndDispose(sessionResource); } + setYieldRequested(sessionResource: URI): void { + const pendingRequest = this._pendingRequests.get(sessionResource); + if (pendingRequest) { + pendingRequest.setYieldRequested(); + } + } + + removePendingRequest(sessionResource: URI, requestId: string): void { + const model = this._sessionModels.get(sessionResource) as ChatModel | undefined; + if (model) { + model.removePendingRequest(requestId); + } + + // Reject the deferred promise for the removed request + const deferred = this._queuedRequestDeferreds.get(requestId); + if (deferred) { + deferred.complete({ kind: 'rejected', reason: 'Request was removed from queue' }); + this._queuedRequestDeferreds.delete(requestId); + } + } + + setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { + const model = this._sessionModels.get(sessionResource) as ChatModel | undefined; + if (model) { + model.setPendingRequests(requests); + } + } + public hasSessions(): boolean { return this._chatSessionStore.hasSessions(); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index ac53b31a898ac..45bfafb65646a 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -206,8 +206,8 @@ export interface IChatSessionsService { readonly _serviceBrand: undefined; // #region Chat session item provider support - readonly onDidChangeItemsProviders: Event; - readonly onDidChangeSessionItems: Event; + readonly onDidChangeItemsProviders: Event<{ readonly chatSessionType: string }>; + readonly onDidChangeSessionItems: Event<{ readonly chatSessionType: string }>; readonly onDidChangeAvailability: Event; readonly onDidChangeInProgress: Event; @@ -215,7 +215,7 @@ export interface IChatSessionsService { getChatSessionContribution(chatSessionType: string): IChatSessionsExtensionPoint | undefined; registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable; - activateChatSessionItemProvider(chatSessionType: string): Promise; + activateChatSessionItemProvider(chatSessionType: string): Promise; getAllChatSessionContributions(): IChatSessionsExtensionPoint[]; getIconForSessionType(chatSessionType: string): ThemeIcon | URI | undefined; @@ -227,13 +227,11 @@ export interface IChatSessionsService { * Get the list of chat session items grouped by session type. * @param providerTypeFilter If specified, only returns items from the given providers. If undefined, returns items from all providers. */ - getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise>; + getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise>; reportInProgress(chatSessionType: string, count: number): void; getInProgress(): { displayName: string; count: number }[]; - // Notify providers about session items changes - notifySessionItemsChanged(chatSessionType: string): void; // #endregion // #region Content provider support diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 13056d6991a73..baa3145870c1e 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,6 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', AgentEnabled = 'chat.agent.enabled', + RequestQueueingEnabled = 'chat.requestQueuing.enabled', AgentStatusEnabled = 'chat.agentsControl.enabled', EditorAssociations = 'chat.editorAssociations', UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 0df77c1014f3a..d3366a2de9a24 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -300,7 +300,7 @@ export interface ILanguageModelsService { /** * Find a model by its qualified name. The qualified name is what is used in prompt and agent files and is in the format "Model Name (Vendor)". */ - lookupLanguageModelByQualifiedName(qualifiedName: string): ILanguageModelChatMetadata | undefined; + lookupLanguageModelByQualifiedName(qualifiedName: string): ILanguageModelChatMetadataAndIdentifier | undefined; getLanguageModelGroups(vendor: string): ILanguageModelsGroup[]; @@ -642,10 +642,10 @@ export class LanguageModelsService implements ILanguageModelsService { return model; } - lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadata | undefined { - for (const model of this._modelCache.values()) { + lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadataAndIdentifier | undefined { + for (const [identifier, model] of this._modelCache.entries()) { if (ILanguageModelChatMetadata.matchesQualifiedName(referenceName, model)) { - return model; + return { metadata: model, identifier }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 343dfce4a595d..c27ff320162f2 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -29,7 +29,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; @@ -39,6 +39,20 @@ import { LocalChatSessionUri } from './chatUri.js'; import { ObjectMutationLog } from './objectMutationLog.js'; +/** + * Represents a queued chat request waiting to be processed. + */ +export interface IChatPendingRequest { + readonly id: string; + readonly request: IChatRequestModel; + readonly kind: ChatRequestQueueKind; + /** + * The options that were passed to sendRequest when this request was queued. + * userSelectedTools is snapshotted to a static observable at queue time. + */ + readonly sendOptions: IChatSendRequestOptions; +} + export const CHAT_ATTACHABLE_IMAGE_MIME_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', @@ -1284,6 +1298,9 @@ export interface IChatModel extends IDisposable { readonly repoData: IExportableRepoData | undefined; setRepoData(data: IExportableRepoData | undefined): void; + + readonly onDidChangePendingRequests: Event; + getPendingRequests(): readonly IChatPendingRequest[]; } export interface ISerializableChatsData { @@ -1781,6 +1798,10 @@ export class ChatModel extends Disposable implements IChatModel { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; + private readonly _pendingRequests: IChatPendingRequest[] = []; + private readonly _onDidChangePendingRequests = this._register(new Emitter()); + readonly onDidChangePendingRequests = this._onDidChangePendingRequests.event; + private _requests: ChatRequestModel[]; private _contributedChatSession: IChatSessionContext | undefined; @@ -1799,6 +1820,89 @@ export class ChatModel extends Disposable implements IChatModel { this._repoData = data; } + getPendingRequests(): readonly IChatPendingRequest[] { + return this._pendingRequests; + } + + setPendingRequests(requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { + const existingMap = new Map(this._pendingRequests.map(p => [p.id, p])); + const newPending: IChatPendingRequest[] = []; + for (const { requestId, kind } of requests) { + const existing = existingMap.get(requestId); + if (existing) { + // Update kind if changed, keep existing request and sendOptions + newPending.push(existing.kind === kind ? existing : { id: existing.id, request: existing.request, kind, sendOptions: existing.sendOptions }); + } + } + this._pendingRequests.length = 0; + this._pendingRequests.push(...newPending); + this._onDidChangePendingRequests.fire(); + } + + /** + * @internal Used by ChatService to add a request to the queue. + * Steering messages are placed before queued messages. + */ + addPendingRequest(request: ChatRequestModel, kind: ChatRequestQueueKind, sendOptions: IChatSendRequestOptions): IChatPendingRequest { + const pendingRequest: IChatPendingRequest = { + id: request.id, + request, + kind, + sendOptions, + }; + + if (kind === ChatRequestQueueKind.Steering) { + // Insert after the last steering message, or at the beginning if there is none + let insertIndex = 0; + for (let i = 0; i < this._pendingRequests.length; i++) { + if (this._pendingRequests[i].kind === ChatRequestQueueKind.Steering) { + insertIndex = i + 1; + } else { + break; + } + } + this._pendingRequests.splice(insertIndex, 0, pendingRequest); + } else { + // Queued messages always go at the end + this._pendingRequests.push(pendingRequest); + } + + this._onDidChangePendingRequests.fire(); + return pendingRequest; + } + + /** + * @internal Used by ChatService to remove a pending request + */ + removePendingRequest(id: string): void { + const index = this._pendingRequests.findIndex(r => r.id === id); + if (index !== -1) { + this._pendingRequests.splice(index, 1); + this._onDidChangePendingRequests.fire(); + } + } + + /** + * @internal Used by ChatService to dequeue the next pending request + */ + dequeuePendingRequest(): IChatPendingRequest | undefined { + const request = this._pendingRequests.shift(); + if (request) { + this._onDidChangePendingRequests.fire(); + } + return request; + } + + /** + * @internal Used by ChatService to clear all pending requests + */ + clearPendingRequests(): void { + if (this._pendingRequests.length > 0) { + this._pendingRequests.length = 0; + this._onDidChangePendingRequests.fire(); + } + } + readonly lastRequestObs: IObservable; // TODO to be clear, this is not the same as the id from the session object, which belongs to the provider. diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index fb7bd5d8a3dc7..e392bdd13b0cb 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -12,7 +12,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js'; @@ -28,6 +28,10 @@ export function isResponseVM(item: unknown): item is IChatResponseViewModel { return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined'; } +export function isPendingDividerVM(item: unknown): item is IChatPendingDividerViewModel { + return !!item && typeof item === 'object' && (item as IChatPendingDividerViewModel).kind === 'pendingDivider'; +} + export function isChatTreeItem(item: unknown): item is IChatRequestViewModel | IChatResponseViewModel { return isRequestVM(item) || isResponseVM(item); } @@ -62,7 +66,7 @@ export interface IChatViewModel { readonly onDidDisposeModel: Event; readonly onDidChange: Event; readonly inputPlaceholder?: string; - getItems(): (IChatRequestViewModel | IChatResponseViewModel)[]; + getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[]; setInputPlaceholder(text: string): void; resetInputPlaceholder(): void; editing?: IChatRequestViewModel; @@ -91,6 +95,8 @@ export interface IChatRequestViewModel { readonly shouldBeBlocked: IObservable; readonly modelId?: string; readonly timestamp: number; + /** The kind of pending request, or undefined if not pending */ + readonly pendingKind?: ChatRequestQueueKind; } export interface IChatResponseMarkdownRenderData { @@ -217,6 +223,15 @@ export interface IChatResponseViewModel { readonly shouldBeBlocked: IObservable; } +export interface IChatPendingDividerViewModel { + readonly kind: 'pendingDivider'; + readonly id: string; // e.g., 'pending-divider-steering' or 'pending-divider-queued' + readonly sessionResource: URI; + readonly isComplete: true; + readonly dividerKind: ChatRequestQueueKind; + currentRenderedHeight: number | undefined; +} + export interface IChatViewModelOptions { /** * Maximum number of items to return from getItems(). @@ -276,6 +291,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { }); this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire())); + this._register(_model.onDidChangePendingRequests(() => this._onDidChange.fire(null))); this._register(_model.onDidChange(e => { if (e.kind === 'addRequest') { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request); @@ -319,11 +335,37 @@ export class ChatViewModel extends Disposable implements IChatViewModel { this._items.push(response); } - getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] { - const items = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop); + getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] { + let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop); if (this._options?.maxVisibleItems !== undefined && items.length > this._options.maxVisibleItems) { - return items.slice(-this._options.maxVisibleItems); + items = items.slice(-this._options.maxVisibleItems); + } + + const pendingRequests = this._model.getPendingRequests(); + if (pendingRequests.length > 0) { + // Separate steering and queued requests + const steeringRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Steering); + const queuedRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Queued); + + // Add steering requests with their divider first + if (steeringRequests.length > 0) { + items.push({ kind: 'pendingDivider', id: 'pending-divider-steering', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Steering, currentRenderedHeight: undefined }); + for (const pending of steeringRequests) { + const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind); + items.push(requestVM); + } + } + + // Add queued requests with their divider + if (queuedRequests.length > 0) { + items.push({ kind: 'pendingDivider', id: 'pending-divider-queued', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Queued, currentRenderedHeight: undefined }); + for (const pending of queuedRequests) { + const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind); + items.push(requestVM); + } + } } + return items; } @@ -429,8 +471,13 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.timestamp; } + get pendingKind() { + return this._pendingKind; + } + constructor( private readonly _model: IChatRequestModel, + private readonly _pendingKind?: ChatRequestQueueKind, ) { } } diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 45e990bf75eb2..6f59271436e96 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -157,6 +157,10 @@ export interface IChatAgentRequest { * Display name of the subagent that is invoking this request. */ subAgentName?: string; + /** + * Set to true by the editor to request the language model gracefully stop after its next opportunity. + */ + yieldRequested?: boolean; /** * The request ID of the parent request that invoked this subagent. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 070cbefa7efe8..7731011cf47ca 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -26,7 +26,6 @@ import { ICustomAgent, IPromptPath, IPromptsService } from './service/promptsSer import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ChatConfiguration, ChatModeKind } from '../constants.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; -import { IChatMode } from '../chatModes.js'; export type InstructionsCollectionEvent = { applyingInstructionsCount: number; @@ -54,7 +53,7 @@ export class ComputeAutomaticInstructions { private _parseResults: ResourceMap = new ResourceMap(); constructor( - private readonly _agent: IChatMode, + private readonly _modeKind: ChatModeKind, private readonly _enabledTools: UserSelectedTools | undefined, private readonly _enabledSubagents: (readonly string[]) | undefined, @IPromptsService private readonly _promptsService: IPromptsService, @@ -119,7 +118,7 @@ export class ComputeAutomaticInstructions { /** public for testing */ public async addApplyingInstructions(instructionFiles: readonly IPromptPath[], context: { files: ResourceSet; instructions: ResourceSet }, variables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { const includeApplyingInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS); - if (!includeApplyingInstructions && this._agent.kind !== ChatModeKind.Edit) { + if (!includeApplyingInstructions && this._modeKind !== ChatModeKind.Edit) { this._logService.trace(`[InstructionsContextComputer] includeApplyingInstructions is disabled and agent kind is not Edit. No applying instructions will be added.`); return; } @@ -400,7 +399,7 @@ export class ComputeAutomaticInstructions { private async _addReferencedInstructions(attachedContext: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { const includeReferencedInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS); - if (!includeReferencedInstructions && this._agent.kind !== ChatModeKind.Edit) { + if (!includeReferencedInstructions && this._modeKind !== ChatModeKind.Edit) { this._logService.trace(`[InstructionsContextComputer] includeReferencedInstructions is disabled and agent kind is not Edit. No referenced instructions will be added.`); return; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 66faf44b6c883..07b7cfd336e3a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -136,8 +136,9 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(baseMessage + '\n\n' + localize('promptHeader.agent.model.githubCopilot', 'Note: This attribute is not used when target is github-copilot.'), node.range); } const modelHoverContent = (modelName: string): Hover | undefined => { - const meta = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); - if (meta) { + const result = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); + if (result) { + const meta = result.metadata; const lines: string[] = []; lines.push(baseMessage + '\n'); lines.push(localize('modelName', '- Name: {0}', meta.name)); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index d582ca6b76bac..698bf5d2e2624 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -334,9 +334,9 @@ export class PromptValidator { } private findModelByName(modelName: string): ILanguageModelChatMetadata | undefined { - const metadata = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); - if (metadata && metadata.isUserSelectable !== false) { - return metadata; + const metadataAndId = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); + if (metadataAndId && metadataAndId.metadata.isUserSelectable !== false) { + return metadataAndId.metadata; } return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 58cc00882f683..a71ce6ba3ed6d 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -17,7 +17,6 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; @@ -39,6 +38,7 @@ import { import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ManageTodoListToolToolId } from './manageTodoListTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; +import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. @@ -63,12 +63,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, - @IChatModeService private readonly chatModeService: IChatModeService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILogService private readonly logService: ILogService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IPromptsService private readonly promptsService: IPromptsService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -142,32 +142,30 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let modeModelId = invocation.modelId; let modeTools = invocation.userSelectedTools; let modeInstructions: IChatRequestModeInstructions | undefined; - let mode: IChatMode | undefined; + let subagent: ICustomAgent | undefined; - if (args.agentName) { - mode = this.chatModeService.findModeByName(args.agentName); - if (mode) { + const subAgentName = args.agentName; + if (subAgentName) { + subagent = await this.getSubAgentByName(subAgentName); + if (subagent) { // Use mode-specific model if available - const modeModelQualifiedNames = mode.model?.get(); + const modeModelQualifiedNames = subagent.model; if (modeModelQualifiedNames) { // Find the actual model identifier from the qualified name(s) - for (const qualifiedName of modeModelQualifiedNames) { + outer: for (const qualifiedName of modeModelQualifiedNames) { const lmByQualifiedName = this.languageModelsService.lookupLanguageModelByQualifiedName(qualifiedName); - for (const fullId of this.languageModelsService.getLanguageModelIds()) { - const lmById = this.languageModelsService.lookupLanguageModel(fullId); - if (lmById && lmById?.id === lmByQualifiedName?.id) { - modeModelId = fullId; - break; - } + if (lmByQualifiedName?.identifier) { + modeModelId = lmByQualifiedName.identifier; + break outer; } } } // Use mode-specific tools if available - const modeCustomTools = mode.customTools?.get(); + const modeCustomTools = subagent.tools; if (modeCustomTools) { // Convert the mode's custom tools (array of qualified names) to UserSelectedTools format - const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, mode.target?.get(), undefined); + const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, subagent.target, undefined); // Convert enablement map to UserSelectedTools (Record) modeTools = {}; for (const [tool, enabled] of enablementMap) { @@ -177,15 +175,15 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } - const instructions = mode.modeInstructions?.get(); + const instructions = subagent.agentInstructions; modeInstructions = instructions && { - name: mode.name.get(), + name: subAgentName, content: instructions.content, toolReferences: this.toolsService.toToolReferences(instructions.toolReferences), metadata: instructions.metadata, }; } else { - throw new Error(`Requested agent '${args.agentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`); + throw new Error(`Requested agent '${subAgentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`); } } @@ -229,7 +227,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, mode ?? ChatMode.Agent, modeTools, undefined); // agents can not call subagents + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined); // agents can not call subagents await computer.collect(variableSet, token); // Build the agent request @@ -241,7 +239,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, subAgentInvocationId: invocation.callId, - subAgentName: mode?.name.get(), + subAgentName: subAgentName, userSelectedModelId: modeModelId, userSelectedTools: modeTools, modeInstructions, @@ -300,17 +298,22 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } + private async getSubAgentByName(name: string): Promise { + const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + return agents.find(agent => agent.name === name); + } + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const args = context.parameters as IRunSubagentToolInputParams; - const mode = args.agentName ? this.chatModeService.findModeByName(args.agentName) : undefined; + const subagent = args.agentName ? await this.getSubAgentByName(args.agentName) : undefined; return { invocationMessage: args.description, toolSpecificData: { kind: 'subagent', description: args.description, - agentName: mode?.name.get(), + agentName: subagent?.name, prompt: args.prompt, }, }; diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index c22c60ca4302b..df8564c61921e 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { findLastIdx } from '../../../../../base/common/arraysFind.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -18,7 +17,7 @@ export function annotateSpecialMarkdownContent(response: Iterable p.kind !== 'textEditGroup' && p.kind !== 'undoStop'); + const previousItemIndex = result.findLastIndex(p => p.kind !== 'textEditGroup' && p.kind !== 'undoStop'); const previousItem = result[previousItemIndex]; if (item.kind === 'inlineReference') { let label: string | undefined = item.name; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index ab75922685a53..064114c7882f7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -750,6 +750,7 @@ suite('AgentSessions', () => { suite('AgentSessionsFilter', () => { const disposables = new DisposableStore(); + const storageKey = 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu'; let mockChatSessionsService: MockChatSessionsService; let instantiationService: TestInstantiationService; @@ -788,7 +789,7 @@ suite('AgentSessions', () => { { filterMenuId: MenuId.ViewTitle } )); - // Default: archived sessions should NOT be excluded (archived: false by default) + // Default: archived sessions should NOT be excluded unless grouped by capped const archivedSession = createSession({ isArchived: () => true }); @@ -827,7 +828,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // After excluding type-1, session1 should be filtered but not session2 assert.strictEqual(filter.exclude(session1), true); @@ -851,14 +852,14 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(session1), true); assert.strictEqual(filter.exclude(session2), true); assert.strictEqual(filter.exclude(session3), false); }); - test('should filter not out archived sessions', () => { + test('should not exclude archived sessions when not capped', () => { const storageService = instantiationService.get(IStorageService); const filter = disposables.add(instantiationService.createInstance( AgentSessionsFilter, @@ -875,7 +876,7 @@ suite('AgentSessions', () => { isArchived: () => false }); - // By default, archived sessions should NOT be filtered (archived: false in default excludes) + // By default, archived sessions should NOT be filtered when not capped assert.strictEqual(filter.exclude(archivedSession), false); assert.strictEqual(filter.exclude(activeSession), false); @@ -885,9 +886,9 @@ suite('AgentSessions', () => { states: [], archived: true }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); - // After excluding archived, only archived session should be filtered + // Archived exclusion only applies when grouped by capped assert.strictEqual(filter.exclude(archivedSession), false); assert.strictEqual(filter.exclude(activeSession), false); }); @@ -925,7 +926,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Failed], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // After excluding failed status, only failedSession should be filtered assert.strictEqual(filter.exclude(failedSession), true); @@ -950,7 +951,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Failed, ChatSessionStatus.InProgress], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(failedSession), true); assert.strictEqual(filter.exclude(completedSession), false); @@ -982,7 +983,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Failed], archived: true }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // session1 should be excluded for multiple reasons assert.strictEqual(filter.exclude(session1), true); @@ -1008,7 +1009,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(changeEventFired, true); }); @@ -1031,7 +1032,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // Should now be excluded assert.strictEqual(filter.exclude(session), true); @@ -1096,7 +1097,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // Nothing should be excluded assert.strictEqual(filter.exclude(session), false); @@ -1117,7 +1118,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(session), false); }); @@ -1144,19 +1145,19 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // filter1 should exclude the session assert.strictEqual(filter1.exclude(session), true); - // filter2 should not exclude the session (different storage key) - assert.strictEqual(filter2.exclude(session), false); + // filter2 should also exclude the session (shared storage key) + assert.strictEqual(filter2.exclude(session), true); }); test('should handle malformed storage data gracefully', () => { const storageService = instantiationService.get(IStorageService); // Store malformed JSON - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, 'invalid json', StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, 'invalid json', StorageScope.PROFILE, StorageTarget.USER); // Filter should still be created with default excludes const filter = disposables.add(instantiationService.createInstance( @@ -1188,7 +1189,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Completed], archived: true }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // Should be excluded due to archived (checked first) assert.strictEqual(filter.exclude(session), true); @@ -1211,7 +1212,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Completed, ChatSessionStatus.InProgress, ChatSessionStatus.Failed], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(completedSession), true); assert.strictEqual(filter.exclude(inProgressSession), true); @@ -1393,7 +1394,7 @@ suite('AgentSessions', () => { instantiationService.stub(IChatSessionsService, mockChatSessionsService); instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); const storageService = instantiationService.get(IStorageService); - storageService.store('agentSessions.readDateBaseline', 1, StorageScope.WORKSPACE, StorageTarget.MACHINE); + storageService.store('agentSessions.readDateBaseline2', 1, StorageScope.WORKSPACE, StorageTarget.MACHINE); }); teardown(() => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 220c5579d9298..b99a9c3138ddd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -146,12 +146,13 @@ suite('AgentSessionsDataSource', () => { function createMockFilter(options: { groupBy?: AgentSessionsGrouping; exclude?: (session: IAgentSession) => boolean; + excludeRead?: boolean; }): IAgentSessionsFilter { return { onDidChange: Event.None, groupResults: () => options.groupBy, exclude: options.exclude ?? (() => false), - getExcludes: () => ({ providers: [], states: [], archived: false, read: false }) + getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }) }; } @@ -447,5 +448,58 @@ suite('AgentSessionsDataSource', () => { assert.strictEqual(olderSection.sessions[0].label, 'Session old2'); assert.strictEqual(olderSection.sessions[1].label, 'Session old1'); }); + + test('capped grouping with unread filter returns flat list without More section', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: '1', startTime: now, isRead: false }), + createMockSession({ id: '2', startTime: now - ONE_DAY, isRead: false }), + createMockSession({ id: '3', startTime: now - 2 * ONE_DAY, isRead: false }), + createMockSession({ id: '4', startTime: now - 3 * ONE_DAY, isRead: false }), + createMockSession({ id: '5', startTime: now - 4 * ONE_DAY, isRead: false }), + ]; + + const filter = createMockFilter({ + groupBy: AgentSessionsGrouping.Capped, + excludeRead: true // Filtering to show only unread sessions + }); + const sorter = createMockSorter(); + const dataSource = new AgentSessionsDataSource(filter, sorter); + + const mockModel = createMockModel(sessions); + const result = Array.from(dataSource.getChildren(mockModel)); + + // Should be a flat list without sections (no More section) + assert.strictEqual(result.length, 5); + assert.strictEqual(getSectionsFromResult(result).length, 0); + }); + + test('capped grouping without unread filter includes More section', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: '1', startTime: now }), + createMockSession({ id: '2', startTime: now - ONE_DAY }), + createMockSession({ id: '3', startTime: now - 2 * ONE_DAY }), + createMockSession({ id: '4', startTime: now - 3 * ONE_DAY }), + createMockSession({ id: '5', startTime: now - 4 * ONE_DAY }), + ]; + + const filter = createMockFilter({ + groupBy: AgentSessionsGrouping.Capped, + excludeRead: false // Not filtering to unread only + }); + const sorter = createMockSorter(); + const dataSource = new AgentSessionsDataSource(filter, sorter); + + const mockModel = createMockModel(sessions); + const result = Array.from(dataSource.getChildren(mockModel)); + + // Should have 3 top sessions + 1 More section + assert.strictEqual(result.length, 4); + const sections = getSectionsFromResult(result); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].section, AgentSessionSection.More); + assert.strictEqual(sections[0].sessions.length, 2); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 3fbe3886f434a..104a1bd528f08 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -17,7 +17,7 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/localAgentSessionsProvider.js'; import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; -import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; @@ -144,6 +144,12 @@ class MockChatService implements IChatService { cancelCurrentRequestForSession(_sessionResource: URI): void { } + setYieldRequested(_sessionResource: URI): void { } + + removePendingRequest(_sessionResource: URI, _requestId: string): void { } + + setPendingRequests(_sessionResource: URI, _requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { } + addCompleteRequest(): void { } async getLocalSessionHistory(): Promise { @@ -801,35 +807,5 @@ suite('LocalAgentsSessionsProvider', () => { assert.strictEqual(changeEventCount, 0, 'onDidChangeChatSessionItems should NOT fire after model is removed'); }); }); - - test('should fire onDidChange when session items change for local type', async () => { - return runWithFakedTimers({}, async () => { - const provider = createProvider(); - - let changeEventFired = false; - disposables.add(provider.onDidChange(() => { - changeEventFired = true; - })); - - mockChatSessionsService.notifySessionItemsChanged(localChatSessionType); - - assert.strictEqual(changeEventFired, true); - }); - }); - - test('should not fire onDidChange when session items change for other types', async () => { - return runWithFakedTimers({}, async () => { - const provider = createProvider(); - - let changeEventFired = false; - disposables.add(provider.onDidChange(() => { - changeEventFired = true; - })); - - mockChatSessionsService.notifySessionItemsChanged('other-type'); - - assert.strictEqual(changeEventFired, false); - }); - }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 322f8893f1897..4ad52ff646c0a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -82,10 +82,10 @@ class MockLanguageModelsService implements ILanguageModelsService { return this.models.get(identifier); } - lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadata | undefined { - for (const metadata of this.models.values()) { + lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadataAndIdentifier | undefined { + for (const [identifier, metadata] of this.models.entries()) { if (ILanguageModelChatMetadata.matchesQualifiedName(referenceName, metadata)) { - return metadata; + return { metadata, identifier }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index 4a8d29fc2b7d7..ebff561a2c048 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -62,7 +62,7 @@ suite('PromptHoverProvider', () => { lookupLanguageModelByQualifiedName(qualifiedName: string) { for (const metadata of testModels) { if (ILanguageModelChatMetadata.matchesQualifiedName(qualifiedName, metadata)) { - return metadata; + return { metadata, identifier: metadata.id }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 45e77e43d2c1b..e156499b3473d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -123,7 +123,7 @@ suite('PromptValidator', () => { lookupLanguageModelByQualifiedName(qualifiedName: string) { for (const metadata of testModels) { if (ILanguageModelChatMetadata.matchesQualifiedName(qualifiedName, metadata)) { - return metadata; + return { metadata, identifier: metadata.id }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 6844fd2cee02a..4473ad266aade 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -22,7 +22,6 @@ import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; -import { ChatTreeItem } from '../../../../browser/chat.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; import { CollapsibleListPool } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js'; @@ -52,7 +51,7 @@ suite('ChatSubagentContentPart', () => { }; return { - element: mockElement as ChatTreeItem, + element: mockElement as IChatResponseViewModel, elementIndex: 0, container: mainWindow.document.createElement('div'), content: [], diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index cd8d46a2abc1b..81ff8e020e991 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -25,7 +25,6 @@ import { ThinkingDisplayMode } from '../../../../common/constants.js'; import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; -import { ChatTreeItem } from '../../../../browser/chat.js'; import { ILanguageModelsService } from '../../../../common/languageModels.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -49,7 +48,7 @@ suite('ChatThinkingContentPart', () => { }; return { - element: mockElement as ChatTreeItem, + element: mockElement as IChatResponseViewModel, elementIndex: 0, container: mainWindow.document.createElement('div'), content: [], diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 4b1c94e37e3dc..f48737b798956 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -37,7 +37,7 @@ import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatEditingService, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { ChatModel, IChatModel, ISerializableChatData } from '../../../common/model/chatModel.js'; -import { IChatFollowup, IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; +import { ChatSendResult, IChatFollowup, IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; @@ -268,8 +268,8 @@ suite('ChatService', () => { const modelRef = testDisposables.add(startSessionModel(testService)); const model = modelRef.object; const response = await testService.sendRequest(model.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); - await response.responseCompletePromise; + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; await assertSnapshot(toSnapshotExportData(model)); }); @@ -294,22 +294,22 @@ suite('ChatService', () => { // Send a request to default agent const response = await testService.sendRequest(model.sessionResource, `test request`, { agentId: 'defaultAgent' }); - assert(response); - await response.responseCompletePromise; + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 1); assert.strictEqual(model.getRequests()[0].response?.result?.metadata?.historyLength, 0); // Send a request to agent2- it can't see the default agent's message const response2 = await testService.sendRequest(model.sessionResource, `test request`, { agentId: 'agent2' }); - assert(response2); - await response2.responseCompletePromise; + ChatSendResult.assertSent(response2); + await response2.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 2); assert.strictEqual(model.getRequests()[1].response?.result?.metadata?.historyLength, 0); // Send a request to defaultAgent - the default agent can see agent2's message const response3 = await testService.sendRequest(model.sessionResource, `test request`, { agentId: 'defaultAgent' }); - assert(response3); - await response3.responseCompletePromise; + ChatSendResult.assertSent(response3); + await response3.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 3); assert.strictEqual(model.getRequests()[2].response?.result?.metadata?.historyLength, 2); }); @@ -326,13 +326,13 @@ suite('ChatService', () => { await assertSnapshot(toSnapshotExportData(model)); const response = await testService.sendRequest(model.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); - await response.responseCompletePromise; + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 1); const response2 = await testService.sendRequest(model.sessionResource, `test request 2`); - assert(response2); - await response2.responseCompletePromise; + ChatSendResult.assertSent(response2); + await response2.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 2); await assertSnapshot(toSnapshotExportData(model)); @@ -351,9 +351,9 @@ suite('ChatService', () => { assert.strictEqual(chatModel1.getRequests().length, 0); const response = await testService.sendRequest(chatModel1.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); + ChatSendResult.assertSent(response); - await response.responseCompletePromise; + await response.data.responseCompletePromise; serializedChatData = JSON.parse(JSON.stringify(chatModel1)); } @@ -382,9 +382,9 @@ suite('ChatService', () => { assert.strictEqual(chatModel1.getRequests().length, 0); const response = await testService.sendRequest(chatModel1.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); + ChatSendResult.assertSent(response); - await response.responseCompletePromise; + await response.data.responseCompletePromise; serializedChatData = JSON.parse(JSON.stringify(chatModel1)); } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 5f104554cab42..2faa8332a53ac 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -10,7 +10,7 @@ import { IObservable, observableValue } from '../../../../../../base/common/obse import { URI } from '../../../../../../base/common/uri.js'; import { IChatModel, IChatRequestModel, IChatRequestVariableData, ISerializableChatData } from '../../../common/model/chatModel.js'; import { IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; -import { IChatCompleteResponse, IChatDetail, IChatModelReference, IChatProgress, IChatProviderInfo, IChatSendRequestData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatCompleteResponse, IChatDetail, IChatModelReference, IChatProgress, IChatProviderInfo, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; export class MockChatService implements IChatService { @@ -71,7 +71,7 @@ export class MockChatService implements IChatService { /** * Returns whether the request was accepted. */ - sendRequest(sessionResource: URI, message: string): Promise { + sendRequest(sessionResource: URI, message: string): Promise { throw new Error('Method not implemented.'); } resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions | undefined): Promise { @@ -86,6 +86,15 @@ export class MockChatService implements IChatService { cancelCurrentRequestForSession(sessionResource: URI): void { throw new Error('Method not implemented.'); } + setYieldRequested(sessionResource: URI): void { + throw new Error('Method not implemented.'); + } + removePendingRequest(sessionResource: URI, requestId: string): void { + throw new Error('Method not implemented.'); + } + setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { + throw new Error('Method not implemented.'); + } addCompleteRequest(sessionResource: URI, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 80e7de23580f2..c08c536df204b 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -19,10 +19,10 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onDidChangeSessionOptions = new Emitter(); readonly onDidChangeSessionOptions = this._onDidChangeSessionOptions.event; - private readonly _onDidChangeItemsProviders = new Emitter(); + private readonly _onDidChangeItemsProviders = new Emitter<{ readonly chatSessionType: string }>(); readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event; - private readonly _onDidChangeSessionItems = new Emitter(); + private readonly _onDidChangeSessionItems = new Emitter<{ readonly chatSessionType: string }>(); readonly onDidChangeSessionItems = this._onDidChangeSessionItems.event; private readonly _onDidChangeAvailability = new Emitter(); @@ -54,7 +54,7 @@ export class MockChatSessionsService implements IChatSessionsService { } fireDidChangeSessionItems(chatSessionType: string): void { - this._onDidChangeSessionItems.fire(chatSessionType); + this._onDidChangeSessionItems.fire({ chatSessionType }); } fireDidChangeAvailability(): void { @@ -86,8 +86,8 @@ export class MockChatSessionsService implements IChatSessionsService { this.contributions = contributions; } - async activateChatSessionItemProvider(chatSessionType: string): Promise { - return this.sessionItemProviders.get(chatSessionType); + async activateChatSessionItemProvider(chatSessionType: string): Promise { + // Noop, nothing to activate } getIconForSessionType(chatSessionType: string): ThemeIcon | URI | undefined { @@ -167,10 +167,6 @@ export class MockChatSessionsService implements IChatSessionsService { await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); } - notifySessionItemsChanged(chatSessionType: string): void { - this._onDidChangeSessionItems.fire(chatSessionType); - } - getSessionOption(sessionResource: URI, optionId: string): string | undefined { return this.sessionOptions.get(sessionResource)?.get(optionId); } diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 026c88b2fa521..f21450267d46b 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; -import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; +import { IChatChangeEvent, IChatModel, IChatPendingRequest, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; @@ -61,6 +61,8 @@ export class MockChatModel extends Disposable implements IChatModel { getRequests(): IChatRequestModel[] { return []; } setCheckpoint(requestId: string | undefined): void { } setRepoData(data: IExportableRepoData | undefined): void { this.repoData = data; } + readonly onDidChangePendingRequests: Event = this._register(new Emitter()).event; + getPendingRequests(): readonly IChatPendingRequest[] { return []; } toExport(): IExportableChatData { return { initialLocation: this.initialLocation, 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 2038fe08f250a..fafc1500aa2cc 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 @@ -39,10 +39,10 @@ import { InMemoryStorageService, IStorageService } from '../../../../../../platf import { IPathService } from '../../../../../services/path/common/pathService.js'; import { IFileQuery, ISearchService } from '../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; -import { ChatMode } from '../../../common/chatModes.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { basename } from '../../../../../../base/common/resources.js'; import { match } from '../../../../../../base/common/glob.js'; +import { ChatModeKind } from '../../../common/constants.js'; suite('ComputeAutomaticInstructions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -218,7 +218,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); { - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -235,7 +235,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, false); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -252,7 +252,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, false); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -269,7 +269,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, false); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -319,7 +319,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -354,7 +354,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Edit, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Edit, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -389,7 +389,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -431,7 +431,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -467,7 +467,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); @@ -503,7 +503,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file1.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file2.ts'))); @@ -537,7 +537,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -581,7 +581,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); const referencedUri = URI.joinPath(rootFolderUri, '.github/instructions/referenced.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -617,7 +617,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -666,7 +666,7 @@ suite('ComputeAutomaticInstructions', () => { const level2Uri = URI.joinPath(rootFolderUri, '.github/instructions/level2.instructions.md'); const level3Uri = URI.joinPath(rootFolderUri, '.github/instructions/level3.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -726,7 +726,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -784,7 +784,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -874,7 +874,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_runSubagent': true }, // Enable runSubagent tool ['*'] // Enable all subagents ); @@ -936,7 +936,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -986,7 +986,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, undefined, // No tools available undefined ); @@ -1022,7 +1022,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -1075,7 +1075,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -1108,7 +1108,7 @@ suite('ComputeAutomaticInstructions', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); await contextComputer.collect(variables, CancellationToken.None); @@ -1140,7 +1140,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1163,7 +1163,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 58bce6f2a9136..306efc1e4da2d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -47,7 +47,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../../../../pl import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; -import { ChatMode } from '../../../../common/chatModes.js'; +import { ChatModeKind } from '../../../../common/constants.js'; suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -464,7 +464,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -635,7 +635,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -709,7 +709,7 @@ suite('PromptsService', () => { ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const context = new ChatRequestVariableSet(); context.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'README.md'))); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index f67bcc11c8eff..25d7088d46e17 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -12,10 +12,11 @@ import { TestConfigurationService } from '../../../../../../../platform/configur import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; -import { IChatModeService } from '../../../../common/chatModes.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; import { ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -45,28 +46,27 @@ suite('RunSubagentTool', () => { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const configService = new TestConfigurationService(); - const mockChatModeService = { - findModeByName: (name: string) => { - if (name === 'CustomAgent') { - return { - name: { - get: () => 'CustomAgent' - } - }; - } - return undefined; - } - } as unknown as IChatModeService; + const promptsService = new MockPromptsService(); + const customMode: ICustomAgent = { + uri: URI.parse('file:///test/custom-agent.md'), + name: 'CustomAgent', + description: 'A test custom agent', + tools: ['tool1', 'tool2'], + agentInstructions: { content: 'Custom agent body', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + visibility: { userInvokable: true, agentInvokable: true } + }; + promptsService.setCustomModes([customMode]); const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, - mockChatModeService, mockToolsService, {} as ILanguageModelsService, new NullLogService(), mockToolsService, configService, + promptsService, {} as IInstantiationService, )); @@ -97,16 +97,17 @@ suite('RunSubagentTool', () => { test('returns basic tool data', () => { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const configService = new TestConfigurationService(); + const promptsService = new MockPromptsService(); const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, - {} as IChatModeService, mockToolsService, {} as ILanguageModelsService, new NullLogService(), mockToolsService, configService, + promptsService, {} as IInstantiationService, )); @@ -124,16 +125,17 @@ suite('RunSubagentTool', () => { const configService = new TestConfigurationService({ 'chat.customAgentInSubagent.enabled': true, }); + const promptsService = new MockPromptsService(); const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, - {} as IChatModeService, mockToolsService, {} as ILanguageModelsService, new NullLogService(), mockToolsService, configService, + promptsService, {} as IInstantiationService, )); diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 8a468452695ef..9c943348a9737 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { distinct } from '../../../../base/common/arrays.js'; -import { findLastIdx } from '../../../../base/common/arraysFind.js'; import { DeferredPromise, RunOnceScheduler } from '../../../../base/common/async.js'; import { VSBuffer, decodeBase64, encodeBase64 } from '../../../../base/common/buffer.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; @@ -1542,7 +1541,7 @@ export class DebugModel extends Disposable implements IDebugModel { let index = -1; if (session.parentSession) { // Make sure that child sessions are placed after the parent session - index = findLastIdx(this.sessions, s => s.parentSession === session.parentSession || s === session.parentSession); + index = this.sessions.findLastIndex(s => s.parentSession === session.parentSession || s === session.parentSession); } if (index >= 0) { this.sessions.splice(index + 1, 0, session); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts index 3b26d58d13ab4..8b06a57a016e4 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts @@ -5,7 +5,6 @@ import * as nls from '../../../../../nls.js'; import * as DOM from '../../../../../base/browser/dom.js'; -import { findLastIdx } from '../../../../../base/common/arraysFind.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IThemeService, registerThemingParticipant } from '../../../../../platform/theme/common/themeService.js'; @@ -774,7 +773,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD this._list.reveal(prevChangeIndex); } else { // go to the last one - const index = findLastIdx(currentViewModels, vm => vm.type !== 'unchanged' && vm.type !== 'unchangedMetadata' && vm.type !== 'placeholder'); + const index = currentViewModels.findLastIndex(vm => vm.type !== 'unchanged' && vm.type !== 'unchangedMetadata' && vm.type !== 'placeholder'); if (index >= 0) { this._list.setFocus([index]); this._list.reveal(index); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index db818b4376494..11ba4d1bbae4b 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -1597,7 +1597,7 @@ export class SettingsEditor2 extends EditorPane { resolvedSettingsRoot.children!.push(await createTocTreeForExtensionSettings(this.extensionService, extensionSettingsGroups, filter)); - resolvedSettingsRoot.children!.unshift(getCommonlyUsedData(groups, toggleData?.commonlyUsed)); + resolvedSettingsRoot.children!.unshift(getCommonlyUsedData(groups)); if (toggleData && setAdditionalGroups) { // Add the additional groups to the model to help with searching. @@ -1671,7 +1671,7 @@ export class SettingsEditor2 extends EditorPane { try { this.settingsTree.reveal(newElement, 0); } catch (e) { - // Ignore the error + // Ignore the error } } } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index a2e37c2a0c076..e23cf45c10115 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -27,14 +27,15 @@ export interface ITOCEntry { hide?: boolean; } -const defaultCommonlyUsedSettings: string[] = [ +const COMMONLY_USED_SETTINGS: readonly string[] = [ 'editor.fontSize', 'editor.formatOnSave', 'files.autoSave', + 'GitHub.copilot-chat.manageExtension', 'editor.defaultFormatter', 'editor.fontFamily', - 'chat.agent.maxRequests', 'editor.wordWrap', + 'chat.agent.maxRequests', 'files.exclude', 'workbench.colorTheme', 'editor.tabSize', @@ -42,7 +43,7 @@ const defaultCommonlyUsedSettings: string[] = [ 'editor.formatOnPaste' ]; -export function getCommonlyUsedData(settingGroups: ISettingsGroup[], commonlyUsed: string[] = defaultCommonlyUsedSettings): ITOCEntry { +export function getCommonlyUsedData(settingGroups: ISettingsGroup[]): ITOCEntry { const allSettings = new Map(); for (const group of settingGroups) { for (const section of group.sections) { @@ -52,7 +53,7 @@ export function getCommonlyUsedData(settingGroups: ISettingsGroup[], commonlyUse } } const settings: ISetting[] = []; - for (const id of commonlyUsed) { + for (const id of COMMONLY_USED_SETTINGS) { const setting = allSettings.get(id); if (setting) { settings.push(setting); diff --git a/src/vs/workbench/contrib/preferences/common/preferences.ts b/src/vs/workbench/contrib/preferences/common/preferences.ts index 2aa5e003d165f..1e6fcb8202723 100644 --- a/src/vs/workbench/contrib/preferences/common/preferences.ts +++ b/src/vs/workbench/contrib/preferences/common/preferences.ts @@ -129,7 +129,6 @@ export enum WorkbenchSettingsEditorSettings { export type ExtensionToggleData = { settingsEditorRecommendedExtensions: IStringDictionary; recommendedExtensionsGalleryInfo: IStringDictionary; - commonlyUsed: string[]; }; let cachedExtensionToggleData: ExtensionToggleData | undefined; @@ -155,7 +154,7 @@ export async function getExperimentalExtensionToggleData( return cachedExtensionToggleData; } - if (productService.extensionRecommendations && productService.commonlyUsedSettings) { + if (productService.extensionRecommendations) { const settingsEditorRecommendedExtensions: IStringDictionary = {}; Object.keys(productService.extensionRecommendations).forEach(extensionId => { const extensionInfo = productService.extensionRecommendations![extensionId]; @@ -188,8 +187,7 @@ export async function getExperimentalExtensionToggleData( cachedExtensionToggleData = { settingsEditorRecommendedExtensions, - recommendedExtensionsGalleryInfo, - commonlyUsed: productService.commonlyUsedSettings + recommendedExtensionsGalleryInfo }; return cachedExtensionToggleData; } diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh index 87e3a63fe0a43..ff974695f6949 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh @@ -108,6 +108,14 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python bash activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" fi fi + # Remove any leftover Python activation env vars. + for var in "${!VSCODE_PYTHON_@}"; do + case "$var" in + VSCODE_PYTHON_*_ACTIVATE) + unset "$var" + ;; + esac + done fi __vsc_get_trap() { diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh index 5389bd95b12da..4869a391ebc37 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh @@ -79,6 +79,8 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python zsh activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" fi fi + # Remove any leftover Python activation env vars. + unset -m 'VSCODE_PYTHON_*_ACTIVATE' fi # Report prompt type diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish index 0e0b6798c168d..6cff7487a712b 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish @@ -95,6 +95,10 @@ if not set -q VSCODE_PYTHON_AUTOACTIVATE_GUARD builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python fish activation failed with exit code %d \x1b[0m \n' "$__vsc_activation_status" end end + # Remove any leftover Python activation env vars. + for var in (set -n | string match -r '^VSCODE_PYTHON_.*_ACTIVATE$') + set -eg $var + end end # Handle the shell integration nonce diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index e89f6ec24c458..a16eaa8534254 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -78,7 +78,6 @@ if (-not $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD) { $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD = '1' if ($env:VSCODE_PYTHON_PWSH_ACTIVATE -and $env:TERM_PROGRAM -eq 'vscode') { $activateScript = $env:VSCODE_PYTHON_PWSH_ACTIVATE - Remove-Item Env:VSCODE_PYTHON_PWSH_ACTIVATE try { Invoke-Expression $activateScript @@ -89,6 +88,8 @@ if (-not $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD) { Write-Host "`e[0m`e[7m * `e[0;103m VS Code Python powershell activation failed with exit code $($activationError.Exception.Message) `e[0m" } } + # Remove any leftover Python activation env vars. + Get-ChildItem Env:VSCODE_PYTHON_*_ACTIVATE | Remove-Item -ErrorAction SilentlyContinue } function Global:__VSCode-Escape-Value([string]$value) { diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 2069029b1d509..262a050262dab 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -7,10 +7,10 @@ import { VSBufferReadableStream } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { isUNC } from '../../../../base/common/extpath.js'; import { Schemas } from '../../../../base/common/network.js'; -import { normalize, sep } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js'; export namespace WebviewResourceResponse { @@ -48,11 +48,12 @@ export async function loadLocalResource( ifNoneMatch: string | undefined; roots: ReadonlyArray; }, + uriIdentityService: IUriIdentityService, fileService: IFileService, logService: ILogService, token: CancellationToken, ): Promise { - const resourceToLoad = getResourceToLoad(requestUri, options.roots); + const resourceToLoad = getResourceToLoad(requestUri, options.roots, uriIdentityService); logService.trace(`Webview.loadLocalResource - trying to load resource. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`); @@ -84,12 +85,14 @@ export async function loadLocalResource( } } -function getResourceToLoad( +export function getResourceToLoad( requestUri: URI, roots: ReadonlyArray, + uriIdentityService: IUriIdentityService, ): URI | undefined { + const requestUriNoQueryString = requestUri.with({ query: '' }); for (const root of roots) { - if (containsResource(root, requestUri)) { + if (containsResource(root, requestUriNoQueryString, uriIdentityService)) { return normalizeResourcePath(requestUri); } } @@ -97,20 +100,30 @@ function getResourceToLoad( return undefined; } -function containsResource(root: URI, resource: URI): boolean { - if (root.scheme !== resource.scheme) { +function containsResource(root: URI, resource: URI, uriIdentityService: IUriIdentityService): boolean { + if (uriIdentityService.extUri.isEqual(root, resource, /* ignoreFragment */ true)) { return false; } - let resourceFsPath = normalize(resource.fsPath); - let rootPath = normalize(root.fsPath + (root.fsPath.endsWith(sep) ? '' : sep)); - - if (isUNC(root.fsPath) && isUNC(resource.fsPath)) { - rootPath = rootPath.toLowerCase(); - resourceFsPath = resourceFsPath.toLowerCase(); + // Compare unc paths case-insensitively + if (root.scheme === Schemas.file && isUNC(root.fsPath)) { + if (resource.scheme === Schemas.file && isUNC(resource.fsPath)) { + return uriIdentityService.extUri.isEqualOrParent( + resource.with({ + path: resource.path.toLowerCase(), + authority: resource.authority.toLowerCase() + }), + root.with({ + path: root.path.toLowerCase(), + authority: root.authority.toLowerCase() + }), + /* ignoreFragment */ true + ); + } + return false; } - return resourceFsPath.startsWith(rootPath); + return uriIdentityService.extUri.isEqualOrParent(resource, root, /* ignoreFragment */ true); } function normalizeResourcePath(resource: URI): URI { diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 2824da9bb2823..5d0d61f811308 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -30,6 +30,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js'; @@ -163,6 +164,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi @ITunnelService private readonly _tunnelService: ITunnelService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); @@ -763,7 +765,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi const result = await loadLocalResource(uri, { ifNoneMatch, roots: this._content.options.localResourceRoots || [], - }, this._fileService, this._logService, this._resourceLoadingCts.token); + }, this._uriIdentityService, this._fileService, this._logService, this._resourceLoadingCts.token); switch (result.type) { case WebviewResourceResponse.Type.Success: { diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 13d75633fb41c..deaf633184c3a 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -19,6 +19,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { WebviewThemeDataProvider } from '../browser/themeing.js'; @@ -56,10 +57,11 @@ export class ElectronWebviewElement extends WebviewElement { @INativeHostService private readonly _nativeHostService: INativeHostService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService accessibilityService: IAccessibilityService, + @IUriIdentityService uriIdentityService: IUriIdentityService, ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, - fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService); + fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService, uriIdentityService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts new file mode 100644 index 0000000000000..2ae1d202ac77e --- /dev/null +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { isWindows } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { getResourceToLoad } from '../../browser/resourceLoading.js'; + +suite('Webview Resource Loading - getResourceToLoad', () => { + const disposableStore = ensureNoDisposablesAreLeakedInTestSuite(); + + let uriIdentityService: IUriIdentityService; + + setup(() => { + const instantiationService = disposableStore.add(new TestInstantiationService()); + instantiationService.stub(ILogService, NullLogService); + const fileService = disposableStore.add(new FileService(instantiationService.get(ILogService))); + uriIdentityService = instantiationService.stub(IUriIdentityService, disposableStore.add(new UriIdentityService(fileService))); + }); + + test('Returns resource when file is under root', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Returns resource when file is in nested directory', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/subdir/nested/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is outside root', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when file is root', () => { + const root = URI.file('/home/user/project'); + const result = getResourceToLoad(root, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when file is sibling of root directory', () => { + const root = URI.file('/home/user/project'); + { + const resource = URI.file('/home/user/projectOther/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + } + { + const resource = URI.file('/home/user/project.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + } + }); + + test('Returns resource when root ends with /', () => { + const root = URI.file('/home/user/project/'); + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails for sibling when root ends with / ', () => { + const root = URI.file('/home/user/project/'); + const resource = URI.file('/home/user/projectOther/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + (!isWindows /* UNC is windows only */ ? suite.skip : suite)('UNC paths', () => { + test('Returns resource when file is under UNC root', () => { + const root = URI.file('\\\\server\\share\\folder'); + const resource = URI.file('\\\\server\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Returns resource with case-insensitive comparison for UNC paths', () => { + const root = URI.file('\\\\SERVER\\SHARE\\folder'); + const resource = URI.file('\\\\server\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is outside UNC root', () => { + const root = URI.file('\\\\server\\share\\folder'); + const resource = URI.file('\\\\server\\share\\other\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when UNC server differs', () => { + const root = URI.file('\\\\server1\\share\\folder'); + const resource = URI.file('\\\\server2\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); + + suite('Different authorities', () => { + test('Returns resource when authorities match', () => { + const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.ok(result); + }); + + test('Fails when authorities differ', () => { + const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server1', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server2', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('handles empty authority', () => { + const root = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + }); + + suite('Different schemes', () => { + test('Fails when schemes differ', () => { + const root = URI.from({ scheme: 'file', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'http', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Returns resource when schemes match', () => { + const root = URI.from({ scheme: 'custom-scheme', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'custom-scheme', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('normalizes vscode-remote scheme', () => { + const root = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + + assert.ok(result); + assert.strictEqual(result.scheme, 'vscode-remote'); + assert.strictEqual(result.authority, 'test'); + assert.strictEqual(result.path, '/vscode-resource'); + const query = JSON.parse(result.query); + assert.strictEqual(query.requestResourcePath, '/home/user/project/file.txt'); + }); + }); + + suite('Fragment and query strings', () => { + test('preserves fragment in returned URI', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.fragment, 'section1'); + }); + + test('preserves query in returned URI', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.query, 'version=2'); + }); + + test('preserves both fragment and query', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1', query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.fragment, 'section1'); + assert.strictEqual(result?.query, 'version=2'); + }); + + test('still validates path containment with query params', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt').with({ query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('still validates path containment with fragment', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt').with({ fragment: 'section1' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); + + suite('Multiple roots', () => { + test('Returns resource when file is under one of multiple roots', () => { + const roots = [ + URI.file('/home/user/project1'), + URI.file('/home/user/project2'), + URI.file('/home/user/project3') + ]; + const resource = URI.file('/home/user/project2/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is not under any root', () => { + const roots = [ + URI.file('/home/user/project1'), + URI.file('/home/user/project2') + ]; + const resource = URI.file('/home/user/other/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Returns resource matching first valid root', () => { + const roots = [ + URI.file('/home/user/project'), + URI.file('/home/user/project/subdir') + ]; + const resource = URI.file('/home/user/project/subdir/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + // Should match first root in the list + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('handles empty roots array', () => { + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); +}); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index 0a90315b88af1..30789ae673c91 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -46,7 +46,7 @@ import { ChatViewId, IChatWidgetService, ISessionTypePickerDelegate, IWorkspaceP import { ChatSessionPosition, getResourceForNewChatSession } from '../../chat/browser/chatSessions/chatSessions.contribution.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { AgentSessionsControl, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js'; -import { IAgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; +import { AgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsFilter.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'; @@ -140,7 +140,7 @@ export class AgentSessionsWelcomePage extends EditorPane { // Telemetry tracking private _openedAt: number = 0; - private _closedBy: string = 'unknown'; + private _closedBy?: string; private _storedInput: AgentSessionsWelcomeInput | undefined; constructor( @@ -202,11 +202,29 @@ export class AgentSessionsWelcomePage extends EditorPane { override async setInput(input: AgentSessionsWelcomeInput, options: AgentSessionsWelcomeEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { this._storedInput = input; + this._openedAt = Date.now(); await super.setInput(input, options, context, token); this._workspaceKind = input.workspaceKind ?? 'empty'; await this.buildContent(); } + override clearInput(): void { + // Send closed telemetry when the editor is closed + if (this._openedAt > 0) { + const visibleDurationMs = Date.now() - this._openedAt; + this.telemetryService.publicLog2( + 'agentSessionsWelcome.closed', + { + visibleDurationMs, + closedBy: this._closedBy ?? 'disposed' + } + ); + this._openedAt = 0; + this._closedBy = undefined; + } + super.clearInput(); + } + private async buildContent(): Promise { this.contentDisposables.clear(); this.sessionsControlDisposables.clear(); @@ -566,32 +584,20 @@ export class AgentSessionsWelcomePage extends EditorPane { // Hide the control initially until loading completes this.sessionsControlContainer.style.display = 'none'; - // Create a filter that limits results and excludes archived sessions - const onDidChangeEmitter = this.sessionsControlDisposables.add(new Emitter()); - const filter: IAgentSessionsFilter = { - onDidChange: onDidChangeEmitter.event, - limitResults: () => MAX_SESSIONS, - exclude: (session: IAgentSession) => session.isArchived(), - getExcludes: () => ({ - providers: [], - states: [], - archived: true, - read: false, - }), - }; - const options: IAgentSessionsControlOptions = { overrideStyles: getListStyles({ listBackground: editorBackground, }), - filter, + filter: this.sessionsControlDisposables.add(this.instantiationService.createInstance(AgentSessionsFilter, { + limitResults: () => MAX_SESSIONS, + })), getHoverPosition: () => HoverPosition.BELOW, trackActiveEditorSession: () => false, source: 'welcomeView', notifySessionOpened: () => { - this._closedBy = 'sessionClicked'; const isProjectionEnabled = this.configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled); if (!isProjectionEnabled) { + this._closedBy = 'sessionClicked'; this.revealMaximizedChat(); } } @@ -909,22 +915,6 @@ export class AgentSessionsWelcomePage extends EditorPane { } } - override dispose(): void { - // Send closed telemetry before disposing - if (this._openedAt > 0) { - const visibleDurationMs = Date.now() - this._openedAt; - this.telemetryService.publicLog2( - 'agentSessionsWelcome.closed', - { - visibleDurationMs, - closedBy: this._closedBy - } - ); - } - - super.dispose(); - } - private async getRecentlyOpenedWorkspaces(onlyTrusted: boolean = false): Promise> { const workspaces = await this.workspacesService.getRecentlyOpened(); const trustInfoPromises = workspaces.workspaces.map(async ws => { diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index 4557cd63aac42..069e1db04be42 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -546,18 +546,18 @@ export class PreferencesService extends Disposable implements IPreferencesServic private getMostCommonlyUsedSettings(): string[] { return [ - 'files.autoSave', 'editor.fontSize', + 'editor.formatOnSave', + 'files.autoSave', + 'editor.defaultFormatter', 'editor.fontFamily', - 'editor.tabSize', - 'editor.renderWhitespace', - 'editor.cursorStyle', - 'editor.multiCursorModifier', - 'editor.insertSpaces', 'editor.wordWrap', + 'chat.agent.maxRequests', 'files.exclude', - 'files.associations', - 'workbench.editor.enablePreview' + 'workbench.colorTheme', + 'editor.tabSize', + 'editor.mouseWheelZoom', + 'editor.formatOnPaste' ]; } diff --git a/src/vs/workbench/services/search/common/textSearchManager.ts b/src/vs/workbench/services/search/common/textSearchManager.ts index 9c7ad25a87980..24cc41e05b98e 100644 --- a/src/vs/workbench/services/search/common/textSearchManager.ts +++ b/src/vs/workbench/services/search/common/textSearchManager.ts @@ -144,26 +144,28 @@ export class TextSearchManager { if (result.uri === undefined) { throw Error('Text search result URI is undefined. Please check provider implementation.'); } - const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri)!; - const hasSibling = folderQuery.folder.scheme === Schemas.file ? - hasSiblingPromiseFn(() => { - return this.fileUtils.readdir(resources.dirname(result.uri)); - }) : - undefined; - - const relativePath = resources.relativePath(folderQuery.folder, result.uri); - if (relativePath) { - // This method is only async when the exclude contains sibling clauses - const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); - if (isThenable(included)) { - testingPs.push( - included.then(isIncluded => { - if (isIncluded) { - onResult(result, folderQuery.folderIdx); - } - })); - } else if (included) { - onResult(result, folderQuery.folderIdx); + const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri); + if (folderQuery?.folder?.scheme) { + const hasSibling = folderQuery.folder.scheme === Schemas.file ? + hasSiblingPromiseFn(() => { + return this.fileUtils.readdir(resources.dirname(result.uri)); + }) : + undefined; + + const relativePath = resources.relativePath(folderQuery.folder, result.uri); + if (relativePath) { + // This method is only async when the exclude contains sibling clauses + const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); + if (isThenable(included)) { + testingPs.push( + included.then(isIncluded => { + if (isIncluded) { + onResult(result, folderQuery.folderIdx); + } + })); + } else if (included) { + onResult(result, folderQuery.folderIdx); + } } } } diff --git a/src/vs/workbench/services/search/test/node/textSearchManager.test.ts b/src/vs/workbench/services/search/test/node/textSearchManager.test.ts index 190ec3005f396..fa034800545d2 100644 --- a/src/vs/workbench/services/search/test/node/textSearchManager.test.ts +++ b/src/vs/workbench/services/search/test/node/textSearchManager.test.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Progress } from '../../../../../platform/progress/common/progress.js'; import { ITextQuery, QueryType } from '../../common/search.js'; -import { ProviderResult, TextSearchComplete2, TextSearchProviderOptions, TextSearchProvider2, TextSearchQuery2, TextSearchResult2 } from '../../common/searchExtTypes.js'; +import { ProviderResult, Range, TextSearchComplete2, TextSearchMatch2, TextSearchProviderOptions, TextSearchProvider2, TextSearchQuery2, TextSearchResult2 } from '../../common/searchExtTypes.js'; import { NativeTextSearchManager } from '../../node/textSearchManager.js'; suite('NativeTextSearchManager', () => { @@ -40,5 +40,52 @@ suite('NativeTextSearchManager', () => { assert.ok(correctEncoding); }); + test('handles result from unmatched folder gracefully via optional chaining', async () => { + let receivedResults = 0; + const provider: TextSearchProvider2 = { + provideTextSearchResults(query: TextSearchQuery2, options: TextSearchProviderOptions, progress: Progress, token: CancellationToken): ProviderResult { + const range = new Range(0, 0, 0, 5); + + // Report a result from a folder that IS in the query - should be received + progress.report(new TextSearchMatch2( + URI.file('/folder1/test.txt'), + [{ sourceRange: range, previewRange: range }], + 'test match' + )); + + // Report a result from a folder that is NOT in the query + // This exercises: folderQuery?.folder?.scheme where folderQuery is undefined + // The optional chaining should handle this gracefully without throwing + progress.report(new TextSearchMatch2( + URI.file('/unknown/folder/file.txt'), + [{ sourceRange: range, previewRange: range }], + 'unmatched result' + )); + + return null; + } + }; + + const query: ITextQuery = { + type: QueryType.Text, + contentPattern: { + pattern: 'a' + }, + folderQueries: [ + { folder: URI.file('/folder1') } + ] + }; + + const m = new NativeTextSearchManager(query, provider); + // This should not throw even though a result from an unmatched folder was reported + await m.search((results) => { + receivedResults += results.length; + }, CancellationToken.None); + + // Should only receive 1 result (the one from /folder1) + // The result from /unknown/folder should be silently ignored + assert.strictEqual(receivedResults, 1); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index f92b2e620a03d..4b117fcb27536 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -344,4 +344,17 @@ declare module 'vscode' { } // #endregion + + // #region Steering + + export interface ChatContext { + /** + * Set to `true` by the editor to request the language model gracefully + * stop after its next opportunity. When set, it's likely that the editor + * will immediately follow up with a new request in the same conversation. + */ + readonly yieldRequested: boolean; + } + + // #endregion } diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 75ce8f4867ddc..49e04398f6d59 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -10,16 +10,11 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.25.2", - "@playwright/mcp": "^0.0.40", - "cors": "^2.8.5", - "express": "^5.2.1", "minimist": "^1.2.8", "ncp": "^2.0.0", "node-fetch": "^2.6.7" }, "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.3", "@types/ncp": "2.0.1", "@types/node": "22.x", "@types/node-fetch": "^2.5.10", @@ -77,92 +72,6 @@ } } }, - "node_modules/@playwright/mcp": { - "version": "0.0.40", - "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.40.tgz", - "integrity": "sha512-gkaE0enMiRLKU3UdVZP2vUn9/rkLT01susE4XY7K10Wpl9vgOXeDCoTNwA2z82D8S2MX31lHx+uveEU4nHF3yw==", - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.56.0-alpha-1758750661000", - "playwright-core": "1.56.0-alpha-1758750661000" - }, - "bin": { - "mcp-server-playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", - "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ncp": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/ncp/-/ncp-2.0.1.tgz", @@ -194,43 +103,6 @@ "form-data": "^4.0.4" } }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -727,20 +599,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1236,36 +1094,6 @@ "node": ">=16.20.0" } }, - "node_modules/playwright": { - "version": "1.56.0-alpha-1758750661000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-1758750661000.tgz", - "integrity": "sha512-15C/m7NPpAmBX2MFMrepCMj18ksBYvhbT90cvFjG2iBs2YPqO2U4f9OjcX207ITSmDAAJ8pWBlJutcZUYUERXg==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.56.0-alpha-1758750661000" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.56.0-alpha-1758750661000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0-alpha-1758750661000.tgz", - "integrity": "sha512-ivP4xjc6EHkUqF80pMFfDRijKLEvO64qC6DTgyYrbsyCo8gugkqwKm6lFWn4W47g4S8juoUwQhlRVjM2BJ+ruA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/test/smoke/src/areas/accessibility/accessibility.test.ts b/test/smoke/src/areas/accessibility/accessibility.test.ts index 9472218a82f43..7f912764a0428 100644 --- a/test/smoke/src/areas/accessibility/accessibility.test.ts +++ b/test/smoke/src/areas/accessibility/accessibility.test.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Application, Logger } from '../../../../automation'; +import { Application, Logger, Quality } from '../../../../automation'; import { installAllHandlers } from '../../utils'; -export function setup(logger: Logger, opts: { web?: boolean }) { - describe.skip('Accessibility', function () { +export function setup(logger: Logger, opts: { web?: boolean }, quality: Quality) { + describe('Accessibility', function () { // Increase timeout for accessibility scans - this.timeout(30 * 1000); + this.timeout(2 * 60 * 1000); // Retry tests to minimize flakiness this.retries(2); @@ -38,7 +38,9 @@ export function setup(logger: Logger, opts: { web?: boolean }) { // Monaco lists use aria-multiselectable on role="list" and aria-setsize/aria-posinset/aria-selected on role="dialog" rows // These violations appear intermittently when notification lists or other dynamic lists are visible // Note: patterns match against HTML string, not CSS selectors, so no leading dots - 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'] + 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'], + // Monaco lists may temporarily contain dialog children during extension activation errors + 'aria-required-children': ['monaco-list'] } }); }); @@ -69,7 +71,7 @@ export function setup(logger: Logger, opts: { web?: boolean }) { }); // Chat is not available in web mode - if (!opts.web) { + if (quality !== Quality.Dev && quality !== Quality.OSS && !opts.web) { describe('Chat', function () { it('chat panel has no accessibility violations', async function () { @@ -87,6 +89,83 @@ export function setup(logger: Logger, opts: { web?: boolean }) { } }); }); + + // Chat response test requires gallery service which is only available in non-Dev/OSS builds + it('chat response has no accessibility violations', async function () { + // Disable retries for this test - it modifies settings and retries cause issues + this.retries(0); + // Extend timeout for this test since AI responses can take a while + this.timeout(3 * 60 * 1000); + + // Enable anonymous chat access + await app.workbench.settingsEditor.addUserSetting('chat.allowAnonymousAccess', 'true'); + + // Open chat panel + await app.workbench.quickaccess.runCommand('workbench.action.chat.open'); + + // Wait for chat view to be visible + await app.workbench.chat.waitForChatView(); + + // Send a simple message + await app.workbench.chat.sendMessage('Create a simple hello.txt file with the text "Hello World"'); + + // Wait for the response to complete (1500 retries ~= 150 seconds at 100ms per retry) + await app.workbench.chat.waitForResponse(1500); + + // Run accessibility check on the chat panel with the response + await app.code.driver.assertNoAccessibilityViolations({ + selector: 'div[id="workbench.panel.chat"]', + excludeRules: { + // Links in chat welcome view show underline on hover/focus which axe-core static analysis cannot detect + 'link-in-text-block': ['command:workbench.action.chat.generateInstructions'], + // Monaco lists use aria-multiselectable on role="list" and aria-selected on role="listitem" + // These are used intentionally for selection semantics even though technically not spec-compliant + 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'], + // Some icon buttons have empty aria-label during rendering + 'aria-command-name': ['codicon-plus'], + // Todo list widget has clear button nested inside expander button for layout purposes + 'nested-interactive': ['todo-list-container'] + } + }); + }); + + it('chat terminal tool response has no accessibility violations', async function () { + // Disable retries for this test + this.retries(0); + // Extend timeout for this test since AI responses can take a while + this.timeout(3 * 60 * 1000); + + // Enable auto-approve for tools so terminal commands run automatically + await app.workbench.settingsEditor.addUserSetting('chat.tools.global.autoApprove', 'true'); + + // Open chat panel + await app.workbench.quickaccess.runCommand('workbench.action.chat.open'); + + // Wait for chat view to be visible + await app.workbench.chat.waitForChatView(); + + // Send a terminal command request + await app.workbench.chat.sendMessage('Run ls in the terminal'); + + // Wait for the response to complete (1500 retries ~= 150 seconds at 100ms per retry) + await app.workbench.chat.waitForResponse(1500); + + // Run accessibility check on the chat panel with the response + await app.code.driver.assertNoAccessibilityViolations({ + selector: 'div[id="workbench.panel.chat"]', + excludeRules: { + // Links in chat welcome view show underline on hover/focus which axe-core static analysis cannot detect + 'link-in-text-block': ['command:workbench.action.chat.generateInstructions'], + // Monaco lists use aria-multiselectable on role="list" and aria-selected on role="listitem" + // These are used intentionally for selection semantics even though technically not spec-compliant + 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'], + // Some icon buttons have empty aria-label during rendering + 'aria-command-name': ['codicon-plus'], + // Todo list widget has clear button nested inside expander button for layout purposes + 'nested-interactive': ['todo-list-container'] + } + }); + }); }); } }); diff --git a/test/smoke/src/areas/chat/chatAnonymous.test.ts b/test/smoke/src/areas/chat/chatAnonymous.test.ts index 5993b99502d83..520292a96d4a7 100644 --- a/test/smoke/src/areas/chat/chatAnonymous.test.ts +++ b/test/smoke/src/areas/chat/chatAnonymous.test.ts @@ -7,7 +7,7 @@ import { Application, Logger } from '../../../../automation'; import { installAllHandlers } from '../../utils'; export function setup(logger: Logger) { - describe('Chat Anonymous', () => { + describe.skip('Chat Anonymous', () => { // Shared before/after handling installAllHandlers(logger); diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index 2360381fd525c..15279bbd5a192 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -408,5 +408,5 @@ describe(`VSCode Smoke Tests (${opts.web ? 'Web' : 'Electron'})`, () => { if (!opts.web && !opts.remote) { setupLaunchTests(logger); } if (!opts.web) { setupChatTests(logger); } if (!opts.web && quality === Quality.Insiders) { setupChatAnonymousTests(logger); } - setupAccessibilityTests(logger, opts); + setupAccessibilityTests(logger, opts, quality); });