diff --git a/build/checker/layersChecker.ts b/build/checker/layersChecker.ts index 87341dcffd09b..174ec2737800b 100644 --- a/build/checker/layersChecker.ts +++ b/build/checker/layersChecker.ts @@ -61,6 +61,12 @@ const RULES: IRule[] = [ disallowedTypes: NATIVE_TYPES, }, + // Browser view preload script + { + target: '**/vs/platform/browserView/electron-browser/preload-browserView.ts', + disallowedTypes: NATIVE_TYPES, + }, + // Common { target: '**/vs/**/common/**', diff --git a/build/checker/tsconfig.electron-browser.json b/build/checker/tsconfig.electron-browser.json index 2cbe3d3bd33ab..80828443aa062 100644 --- a/build/checker/tsconfig.electron-browser.json +++ b/build/checker/tsconfig.electron-browser.json @@ -16,6 +16,7 @@ "../../src/**/test/**", "../../src/**/fixtures/**", "../../src/vs/base/parts/sandbox/electron-browser/preload.ts", // Preload scripts for Electron sandbox - "../../src/vs/base/parts/sandbox/electron-browser/preload-aux.ts" // have limited access to node.js APIs + "../../src/vs/base/parts/sandbox/electron-browser/preload-aux.ts", // have limited access to node.js APIs + "../../src/vs/platform/browserView/electron-browser/preload-browserView.ts" // Browser view preload script ] } diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 8bc20da0c12f7..0ccfb520c8a78 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -68,6 +68,7 @@ const vscodeResourceIncludes = [ // Electron Preload 'out-build/vs/base/parts/sandbox/electron-browser/preload.js', 'out-build/vs/base/parts/sandbox/electron-browser/preload-aux.js', + 'out-build/vs/platform/browserView/electron-browser/preload-browserView.js', // Node Scripts 'out-build/vs/base/node/{terminateProcess.sh,cpuUsage.sh,ps.sh}', diff --git a/build/lib/electron.ts b/build/lib/electron.ts index 64786cb2de7e2..8d4eb0f8844c0 100644 --- a/build/lib/electron.ts +++ b/build/lib/electron.ts @@ -229,15 +229,9 @@ function getElectron(arch: string): () => NodeJS.ReadWriteStream { } async function main(arch: string = process.arch): Promise { - const version = electronVersion; const electronPath = path.join(root, '.build', 'electron'); - const versionFile = path.join(electronPath, versionedResourcesFolder, 'version'); - const isUpToDate = fs.existsSync(versionFile) && fs.readFileSync(versionFile, 'utf8') === `${version}`; - - if (!isUpToDate) { - await util.rimraf(electronPath)(); - await util.streamToPromise(getElectron(arch)()); - } + await util.rimraf(electronPath)(); + await util.streamToPromise(getElectron(arch)()); } if (import.meta.main) { diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index 74d25c65ae0c0..3e6c9f0ad4955 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -165,6 +165,8 @@ export function activate(context: vscode.ExtensionContext) { outputChannel.appendLine(`Launching server: "${serverCommandPath}" ${commandArgs.join(' ')}`); const shell = (process.platform === 'win32'); + // Skip prelaunch to avoid redownloading electron while it may be in use + env['VSCODE_SKIP_PRELAUNCH'] = '1'; extHostProcess = cp.spawn(serverCommandPath, commandArgs, { env, cwd: vscodePath, shell }); } else { const extensionToInstall = process.env['TESTRESOLVER_INSTALL_BUILTIN_EXTENSION']; diff --git a/package-lock.json b/package-lock.json index a08917c04557e..8eccd8dcd312e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -3364,9 +3364,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.37.0.tgz", - "integrity": "sha512-FDBc/3qf7fLMp4fmdRBav2dy3UZ/Vao4PN6a5IeTYvcgh9erd9HfOcVoU3ogy2uwCii6vZNvmEeF9+gr64spVQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.38.0.tgz", + "integrity": "sha512-f8fOGbYhCVG9FUbtVcL/90yjCyo6ZuuKQpA7hs7iIEMD8kesnoo04TUI3/29vifCZ2DCiyUN12CFgA+ktc2RTw==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 8b2c972311791..c5633f6bad53c 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", diff --git a/remote/package-lock.json b/remote/package-lock.json index cec191c66d1fa..b0b3663832fed 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -15,7 +15,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", @@ -468,9 +468,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.37.0.tgz", - "integrity": "sha512-FDBc/3qf7fLMp4fmdRBav2dy3UZ/Vao4PN6a5IeTYvcgh9erd9HfOcVoU3ogy2uwCii6vZNvmEeF9+gr64spVQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.38.0.tgz", + "integrity": "sha512-f8fOGbYhCVG9FUbtVcL/90yjCyo6ZuuKQpA7hs7iIEMD8kesnoo04TUI3/29vifCZ2DCiyUN12CFgA+ktc2RTw==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index fa80a9ace3180..a89be0e24ebd2 100644 --- a/remote/package.json +++ b/remote/package.json @@ -10,7 +10,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 1b3e2d595b6b8..96e950596204e 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -427,6 +427,7 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { private readonly _dropdown: DropdownMenuActionViewItem; private _container: HTMLElement | null = null; private readonly _storageKey: string; + private readonly _primaryActionListener = this._register(new MutableDisposable()); get onDidChangeDropdownVisibility(): Event { return this._dropdown.onDidChangeVisibility; @@ -468,14 +469,18 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { this._dropdown = this._register(new DropdownMenuActionViewItem(submenuAction, submenuAction.actions, this._contextMenuService, dropdownOptions)); if (options?.togglePrimaryAction) { - this._register(this._dropdown.actionRunner.onDidRun((e: IRunEvent) => { - if (e.action instanceof MenuItemAction) { - this.update(e.action); - } - })); + this.registerTogglePrimaryActionListener(); } } + private registerTogglePrimaryActionListener(): void { + this._primaryActionListener.value = this._dropdown.actionRunner.onDidRun((e: IRunEvent) => { + if (e.action instanceof MenuItemAction) { + this.update(e.action); + } + }); + } + private update(lastAction: MenuItemAction): void { if (this._options?.togglePrimaryAction) { this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE); @@ -516,6 +521,9 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { this._defaultAction.actionRunner = actionRunner; this._dropdown.actionRunner = actionRunner; + if (this._primaryActionListener.value) { + this.registerTogglePrimaryActionListener(); + } } override get actionRunner(): IActionRunner { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index df136bd178e0d..f22fd39e70b0c 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -117,6 +117,11 @@ export enum BrowserViewStorageScope { export const ipcBrowserViewChannelName = 'browserView'; +/** + * This should match the isolated world ID defined in `preload-browserView.ts`. + */ +export const browserViewIsolatedWorldId = 999; + export interface IBrowserViewService { /** * Dynamic events that return an Event for a specific browser view ID. @@ -246,6 +251,14 @@ export interface IBrowserViewService { */ stopFindInPage(id: string, keepSelection?: boolean): Promise; + /** + * Get the currently selected text in the browser view. + * Returns immediately with empty string if the page is still loading. + * @param id The browser view identifier + * @returns The selected text, or empty string if no selection or page is loading + */ + getSelectedText(id: string): Promise; + /** * Clear all storage data for the global browser session */ diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts new file mode 100644 index 0000000000000..29832f220ff95 --- /dev/null +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-restricted-globals */ + +/** + * Preload script for pages loaded in Integrated Browser + * + * It runs in an isolated context that Electron calls an "isolated world". + * Specifically the isolated world with worldId 999, which shows in DevTools as "Electron Isolated Context". + * Despite being isolated, it still runs on the same page as the JS from the actual loaded website + * which runs on the so-called "main world" (worldId 0. In DevTools as "top"). + * + * Learn more: see Electron docs for Security, contextBridge, and Context Isolation. + */ +(function () { + + const { contextBridge } = require('electron'); + + // ####################################################################### + // ### ### + // ### !!! DO NOT USE GET/SET PROPERTIES ANYWHERE HERE !!! ### + // ### !!! UNLESS THE ACCESS IS WITHOUT SIDE EFFECTS !!! ### + // ### (https://github.com/electron/electron/issues/25516) ### + // ### ### + // ####################################################################### + const globals = { + /** + * Get the currently selected text in the page. + */ + getSelectedText(): string { + try { + // Even if the page has overridden window.getSelection, our call here will still reach the original + // implementation. That's because Electron proxies functions, such as getSelectedText here, that are + // exposed to a different context via exposeInIsolatedWorld or exposeInMainWorld. + return window.getSelection()?.toString() ?? ''; + } catch { + return ''; + } + } + }; + + try { + // Use `contextBridge` APIs to expose globals to the same isolated world where this preload script runs (worldId 999). + // The globals object will be recursively frozen (and for functions also proxied) by Electron to prevent + // modification within the given context. + contextBridge.exposeInIsolatedWorld(999, 'browserViewAPI', globals); + } catch (error) { + console.error(error); + } +}()); diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index ab70aa81dea4c..3154da6ccb18d 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { WebContentsView, webContents } from 'electron'; +import { FileAccess } from '../../../base/common/network.js'; 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, BrowserNewPageLocation } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId } 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'; @@ -96,6 +97,7 @@ export class BrowserView extends Disposable { sandbox: true, webviewTag: false, session: viewSession, + preload: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath, // TODO@kycutler: Remove this once https://github.com/electron/electron/issues/42578 is fixed type: 'browserView' @@ -535,6 +537,23 @@ export class BrowserView extends Disposable { this._view.webContents.stopFindInPage(keepSelection ? 'keepSelection' : 'clearSelection'); } + /** + * Get the currently selected text in the browser view. + * Returns immediately with empty string if the page is still loading. + */ + async getSelectedText(): Promise { + // we don't want to wait for the page to finish loading, which executeJavaScript normally does. + if (this._view.webContents.isLoading()) { + return ''; + } + try { + // Uses our preloaded contextBridge-exposed API. + return await this._view.webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [{ code: 'window.browserViewAPI?.getSelectedText?.() ?? ""' }]); + } catch { + return ''; + } + } + /** * Clear all storage data for this browser view's session */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 66f9d2bb825d7..7932a442087b4 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -243,6 +243,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).stopFindInPage(keepSelection); } + async getSelectedText(id: string): Promise { + return this._getBrowserView(id).getSelectedText(); + } + async clearStorage(id: string): Promise { return this._getBrowserView(id).clearStorage(); } diff --git a/src/vs/workbench/api/browser/mainThreadHooks.ts b/src/vs/workbench/api/browser/mainThreadHooks.ts index 3265f82878e50..0283383ac65e5 100644 --- a/src/vs/workbench/api/browser/mainThreadHooks.ts +++ b/src/vs/workbench/api/browser/mainThreadHooks.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; import { HookResultKind, IHookResult, IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooksExecutionService.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { HookTypeValue, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; @extHostNamedCustomer(MainContext.MainThreadHooks) export class MainThreadHooks extends Disposable implements MainThreadHooksShape { @@ -20,17 +21,21 @@ export class MainThreadHooks extends Disposable implements MainThreadHooksShape super(); const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks); - // Adapter that implements IHooksExecutionProxy by forwarding to ExtHostHooksShape const proxy: IHooksExecutionProxy = { - executeHook: async (hookType: HookTypeValue, sessionResource: URI, input: unknown): Promise => { - const results = await extHostProxy.$executeHook(hookType, sessionResource, input); - return results.map(r => ({ - kind: r.kind as HookResultKind, - result: r.result - })); + runHookCommand: async (hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise => { + const result = await extHostProxy.$runHookCommand(hookCommand, input, token); + return { + kind: result.kind as HookResultKind, + result: result.result + }; } }; this._hooksExecutionService.setProxy(proxy); } + + async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise { + const uri = URI.revive(sessionResource); + return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token }); + } } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 6cec4600e3449..04e055c5b92c7 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -252,7 +252,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostDialogs = new ExtHostDialogs(rpcProtocol); const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); const extHostHooks = accessor.get(IExtHostHooks); - extHostHooks.initialize(extHostChatAgents2); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -1595,11 +1594,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, - executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Thenable { + async executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Promise { checkProposedApiEnabled(extension, 'chatHooks'); - return extHostHooks.executeHook(hookType, options, token).then(results => - results.map(r => ({ kind: r.kind as unknown as vscode.ChatHookResultKind, result: r.result })) - ); + return extHostHooks.executeHook(hookType, options, token); }, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 6489598d42ca0..cb91ae6e736fe 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -99,6 +99,8 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; +import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export type IconPathDto = | UriComponents @@ -3197,17 +3199,12 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } -export interface IHookResultDto { - readonly kind: number; - readonly result: string | object; -} +export type IHookCommandDto = Dto; export interface ExtHostHooksShape { - $executeHook(hookType: string, sessionResource: UriComponents, input: unknown): Promise; + $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise; } - - export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; $resolveMcpLaunch(collectionId: string, label: string): Promise; @@ -3262,7 +3259,7 @@ export interface MainThreadDataChannelsShape extends IDisposable { } export interface MainThreadHooksShape extends IDisposable { - // Empty - main thread only calls extension host, no callbacks needed + $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise; } export interface ExtHostDataChannelsShape { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index b15c6fc1fd31e..c86cd00bc3eae 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -525,12 +525,6 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return agent.apiAgent; } - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { - const sessionResourceString = sessionResource.toString(); - const request = [...this._inFlightRequests].find(r => r.extRequest.sessionResource.toString() === sessionResourceString); - return request?.hooks; - } - registerChatParticipantDetectionProvider(extension: IExtensionDescription, provider: vscode.ChatParticipantDetectionProvider): vscode.Disposable { const handle = ExtHostChatAgents2._participantDetectionProviderIdPool++; this._participantDetectionProviders.set(handle, new ExtHostParticipantDetector(extension, provider)); diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts index 01c69be62d119..d03d803c47c30 100644 --- a/src/vs/workbench/api/common/extHostHooks.ts +++ b/src/vs/workbench/api/common/extHostHooks.ts @@ -3,13 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type * as vscode from 'vscode'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { UriComponents } from '../../../base/common/uri.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; -import { IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { ExtHostHooksShape, IHookResultDto } from './extHost.protocol.js'; -import { ExtHostChatAgents2 } from './extHostChatAgents2.js'; +import { ExtHostHooksShape } from './extHost.protocol.js'; export const IExtHostHooks = createDecorator('IExtHostHooks'); @@ -19,7 +17,5 @@ export interface IChatHookExecutionOptions { } export interface IExtHostHooks extends ExtHostHooksShape { - initialize(extHostChatAgents: ExtHostChatAgents2): void; - executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; - $executeHook(hookType: string, sessionResource: UriComponents, input: unknown): Promise; + executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 24451432ff5d4..316a4e5606f07 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -45,6 +45,7 @@ import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCom import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; +import { HookResultKind, IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -3999,3 +4000,14 @@ export namespace SourceControlInputBoxValidationType { } } } + +export namespace ChatHookResult { + export function to(result: IHookResult): vscode.ChatHookResult { + return { + kind: result.kind === HookResultKind.Success + ? types.ChatHookResultKind.Success + : types.ChatHookResultKind.Error, + result: result.result + }; + } +} diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts index 36c6ef91e7449..db4d738600af2 100644 --- a/src/vs/workbench/api/node/extHostHooksNode.ts +++ b/src/vs/workbench/api/node/extHostHooksNode.ts @@ -3,97 +3,66 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type * as vscode from 'vscode'; import { spawn } from 'child_process'; import { homedir } from 'os'; import { disposableTimeout } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookResultDto } from '../common/extHost.protocol.js'; -import { ExtHostChatAgents2 } from '../common/extHostChatAgents2.js'; +import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; +import { IExtHostRpcService } from '../common/extHostRpcService.js'; import { HookResultKind, IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; +import * as typeConverters from '../common/extHostTypeConverters.js'; const SIGKILL_DELAY_MS = 5000; export class NodeExtHostHooks implements IExtHostHooks { - private _extHostChatAgents: ExtHostChatAgents2 | undefined; + private readonly _mainThreadProxy: MainThreadHooksShape; constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, @ILogService private readonly _logService: ILogService - ) { } - - initialize(extHostChatAgents: ExtHostChatAgents2): void { - this._extHostChatAgents = extHostChatAgents; + ) { + this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); } - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!this._extHostChatAgents) { - throw new Error('ExtHostHooks not initialized'); - } - + async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { throw new Error('Invalid or missing tool invocation token'); } const context = options.toolInvocationToken as IToolInvocationContext; - return this._executeHooks(hookType, context.sessionResource, options.input, token); - } - async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown): Promise { - if (!this._extHostChatAgents) { - return []; - } - - const uri = URI.revive(sessionResource); - const results = await this._executeHooks(hookType as HookTypeValue, uri, input, undefined); - return results.map(r => ({ kind: r.kind, result: r.result })); + const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); + return results.map(r => typeConverters.ChatHookResult.to({ + kind: r.kind as HookResultKind, + result: r.result + })); } - private async _executeHooks(hookType: HookTypeValue, sessionResource: URI, input: unknown, token?: CancellationToken): Promise { - const hooks = this._extHostChatAgents!.getHooksForSession(sessionResource); - if (!hooks) { - return []; - } + async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise { + this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); - const hookCommands = this._getHooksForType(hooks, hookType); - if (!hookCommands || hookCommands.length === 0) { - return []; - } - - this._logService.debug(`[ExtHostHooks] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); - this._logService.trace(`[ExtHostHooks] Hook input:`, input); - - const results: IHookResult[] = []; - for (const hookCommand of hookCommands) { - try { - this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); - const result = await this._executeCommand(hookCommand, input, token); - this._logService.debug(`[ExtHostHooks] Hook completed with result kind: ${result.kind === HookResultKind.Success ? 'Success' : 'Error'}`); - this._logService.trace(`[ExtHostHooks] Hook output:`, result.result); - results.push(result); - } catch (err) { - this._logService.debug(`[ExtHostHooks] Hook failed with error: ${err instanceof Error ? err.message : String(err)}`); - results.push({ - kind: HookResultKind.Error, - result: err instanceof Error ? err.message : String(err) - }); - } + try { + return await this._executeCommand(hookCommand, input, token); + } catch (err) { + return { + kind: HookResultKind.Error, + result: err instanceof Error ? err.message : String(err) + }; } - return results; - } - - private _getHooksForType(hooks: IChatRequestHooks, hookType: HookTypeValue): readonly IHookCommand[] | undefined { - return hooks[hookType]; } - private _executeCommand(hook: IHookCommand, input: unknown, token?: CancellationToken): Promise { + private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise { const home = homedir(); - const cwd = hook.cwd ? hook.cwd.fsPath : home; + const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined; + const cwd = cwdUri ? cwdUri.fsPath : home; // Determine command and args based on which property is specified // For bash/powershell: spawn the shell directly with explicit args to avoid double shell wrapping diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 8ee357c7ee539..48be84370399c 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -424,6 +424,7 @@ async function lookupProxyAuthorization( proxyAuthenticate: string | string[] | undefined, state: { kerberosRequested?: boolean; basicAuthCacheUsed?: boolean; basicAuthAttempt?: number } ): Promise { + proxyURL = proxyURL.replace(/\/+$/, ''); const cached = proxyAuthenticateCache[proxyURL]; if (proxyAuthenticate) { proxyAuthenticateCache[proxyURL] = proxyAuthenticate; diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts index 603fedbeceec4..0ed5021c0795a 100644 --- a/src/vs/workbench/api/test/node/extHostHooks.test.ts +++ b/src/vs/workbench/api/test/node/extHostHooks.test.ts @@ -4,17 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../platform/log/common/log.js'; import { NodeExtHostHooks } from '../../node/extHostHooksNode.js'; -import { HookType, IChatRequestHooks, IHookCommand } from '../../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { ExtHostChatAgents2 } from '../../common/extHostChatAgents2.js'; -import { IToolInvocationContext } from '../../../contrib/chat/common/tools/languageModelToolsService.js'; -import { ChatHookResultKind } from '../../common/extHostTypes.js'; +import { IHookCommandDto, MainThreadHooksShape } from '../../common/extHost.protocol.js'; +import { IHookResult, HookResultKind } from '../../../contrib/chat/common/hooksExecutionService.js'; +import { IExtHostRpcService } from '../../common/extHostRpcService.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; -function createHookCommand(command: string, options?: Partial>): IHookCommand { +function createHookCommandDto(command: string, options?: Partial>): IHookCommandDto { return { type: 'command', command, @@ -22,269 +21,99 @@ function createHookCommand(command: string, options?: Partial { - return { - getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { - return hooks; - } - }; + _serviceBrand: undefined, + getProxy(): T { + return mainThreadProxy as unknown as T; + }, + set(_identifier: unknown, instance: R): R { + return instance; + }, + dispose(): void { }, + assertRegistered(): void { }, + drain(): Promise { return Promise.resolve(); }, + } as IExtHostRpcService; } suite.skip('ExtHostHooks', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + ensureNoDisposablesAreLeakedInTestSuite(); let hooksService: NodeExtHostHooks; - let sessionResource: URI; setup(() => { - hooksService = new NodeExtHostHooks(new NullLogService()); - sessionResource = URI.parse('vscode-chat-session://test-session'); - }); - - test('executeHook throws when not initialized', async () => { - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - - await assert.rejects( - () => hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ), - /ExtHostHooks not initialized/ - ); - }); - - test('executeHook throws with invalid tool invocation token', async () => { - hooksService.initialize(createMockExtHostChatAgents(undefined) as ExtHostChatAgents2); - - await assert.rejects( - () => hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken: undefined }, - undefined - ), - /Invalid or missing tool invocation token/ - ); - - await assert.rejects( - () => hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken: { invalid: 'token' } }, - undefined - ), - /Invalid or missing tool invocation token/ - ); - }); - - test('executeHook returns empty array when no hooks found for session', async () => { - hooksService.initialize(createMockExtHostChatAgents(undefined) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); - - assert.deepStrictEqual(results, []); - }); - - test('executeHook returns empty array when no hooks of specified type exist', async () => { - const hooks: IChatRequestHooks = { - // Only preToolUse hooks, no sessionStart - [HookType.PreToolUse]: [createHookCommand('echo "pre-tool"')] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); - - assert.deepStrictEqual(results, []); - }); - - test('executeHook runs command and returns success result', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [createHookCommand('echo "hello world"')] + const mockMainThreadProxy: MainThreadHooksShape = { + $executeHook: async (): Promise => { + return []; + }, + dispose: () => { } }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].kind, ChatHookResultKind.Success); - assert.strictEqual((results[0].result as string).trim(), 'hello world'); + const mockRpcService = createMockExtHostRpcService(mockMainThreadProxy); + hooksService = new NodeExtHostHooks(mockRpcService, new NullLogService()); }); - test('executeHook parses JSON output', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [createHookCommand('echo \'{"key": "value"}\'')] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); + test('$runHookCommand runs command and returns success result', async () => { + const hookCommand = createHookCommandDto('echo "hello world"'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].kind, ChatHookResultKind.Success); - assert.deepStrictEqual(results[0].result, { key: 'value' }); + assert.strictEqual(result.kind, HookResultKind.Success); + assert.strictEqual((result.result as string).trim(), 'hello world'); }); - test('executeHook returns error result for non-zero exit code', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [createHookCommand('exit 1')] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + test('$runHookCommand parses JSON output', async () => { + const hookCommand = createHookCommandDto('echo \'{"key": "value"}\''); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].kind, ChatHookResultKind.Error); + assert.strictEqual(result.kind, HookResultKind.Success); + assert.deepStrictEqual(result.result, { key: 'value' }); }); - test('executeHook captures stderr on failure', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [createHookCommand('echo "error message" >&2 && exit 1')] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); + test('$runHookCommand returns error result for non-zero exit code', async () => { + const hookCommand = createHookCommandDto('exit 1'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].kind, ChatHookResultKind.Error); - assert.strictEqual((results[0].result as string).trim(), 'error message'); + assert.strictEqual(result.kind, HookResultKind.Error); }); - test('executeHook handles multiple hooks', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [ - createHookCommand('echo "first"'), - createHookCommand('echo "second"'), - createHookCommand('echo "third"') - ] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); + test('$runHookCommand captures stderr on failure', async () => { + const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - assert.strictEqual(results.length, 3); - assert.strictEqual(results[0].kind, ChatHookResultKind.Success); - assert.strictEqual((results[0].result as string).trim(), 'first'); - assert.strictEqual(results[1].kind, ChatHookResultKind.Success); - assert.strictEqual((results[1].result as string).trim(), 'second'); - assert.strictEqual(results[2].kind, ChatHookResultKind.Success); - assert.strictEqual((results[2].result as string).trim(), 'third'); + assert.strictEqual(result.kind, HookResultKind.Error); + assert.strictEqual((result.result as string).trim(), 'error message'); }); - test('executeHook passes input to stdin as JSON', async () => { - const hooks: IChatRequestHooks = { - [HookType.PreToolUse]: [createHookCommand('cat')] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); + test('$runHookCommand passes input to stdin as JSON', async () => { + const hookCommand = createHookCommandDto('cat'); const input = { tool: 'bash', args: { command: 'ls' } }; - const results = await hooksService.executeHook( - HookType.PreToolUse, - { toolInvocationToken, input }, - undefined - ); + const result = await hooksService.$runHookCommand(hookCommand, input, CancellationToken.None); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].kind, ChatHookResultKind.Success); - assert.deepStrictEqual(results[0].result, input); + assert.strictEqual(result.kind, HookResultKind.Success); + assert.deepStrictEqual(result.result, input); }); - test('executeHook respects cancellation', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [createHookCommand('sleep 10')] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const cts = disposables.add(new CancellationTokenSource()); - - const resultPromise = hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - cts.token - ); + test('$runHookCommand returns error for invalid command', async () => { + const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - // Cancel after a short delay - setTimeout(() => cts.cancel(), 50); - - const results = await resultPromise; - assert.strictEqual(results.length, 1); - // Cancelled commands return error result - assert.strictEqual(results[0].kind, ChatHookResultKind.Error); + assert.strictEqual(result.kind, HookResultKind.Error); }); - test('executeHook returns error for invalid command', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [createHookCommand('/nonexistent/command/that/does/not/exist')] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); + test('$runHookCommand uses custom environment variables', async () => { + const hookCommand = createHookCommandDto('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } }); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].kind, ChatHookResultKind.Error); + assert.strictEqual(result.kind, HookResultKind.Success); + assert.strictEqual((result.result as string).trim(), 'custom_value'); }); - test('executeHook uses custom environment variables', async () => { - const hooks: IChatRequestHooks = { - [HookType.SessionStart]: [createHookCommand('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } })] - }; - hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); - - const toolInvocationToken = createMockToolInvocationContext(sessionResource); - const results = await hooksService.executeHook( - HookType.SessionStart, - { toolInvocationToken }, - undefined - ); + test('$runHookCommand uses custom cwd', async () => { + const hookCommand = createHookCommandDto('pwd', { cwd: URI.file('/tmp') }); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].kind, ChatHookResultKind.Success); - assert.strictEqual((results[0].result as string).trim(), 'custom_value'); + assert.strictEqual(result.kind, HookResultKind.Success); + // The result should contain /tmp or /private/tmp (macOS symlink) + assert.ok((result.result as string).includes('tmp')); }); }); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index a292a3a1ba259..732fa1974e481 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -118,6 +118,7 @@ export interface IBrowserViewModel extends IDisposable { focus(): Promise; findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise; stopFindInPage(keepSelection?: boolean): Promise; + getSelectedText(): Promise; clearStorage(): Promise; } @@ -336,6 +337,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.stopFindInPage(this.id, keepSelection); } + async getSelectedText(): Promise { + return this.browserViewService.getSelectedText(this.id); + } + async clearStorage(): Promise { return this.browserViewService.clearStorage(this.id); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index af25fe124da7f..445507485ca46 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -600,10 +600,15 @@ export class BrowserEditor extends EditorPane { } /** - * Show the find widget + * Show the find widget, optionally pre-populated with selected text from the browser view */ - showFind(): void { - this._findWidget.value.reveal(); + async showFind(): Promise { + // Get selected text from the browser view to pre-populate the search box. + const selectedText = await this._model?.getSelectedText(); + + // Only use the selected text if it doesn't contain newlines (single line selection) + const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; + this._findWidget.value.reveal(textToReveal); this._findWidget.value.layout(this._findWidgetContainer.clientWidth); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 3e4f7655a1e2e..7605b5b69fe72 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -822,7 +822,8 @@ export class CancelAction extends Action2 { id: MenuId.ChatExecute, when: ContextKeyExpr.and( ChatContextKeys.requestInProgress, - ChatContextKeys.remoteJobCreating.negate() + ChatContextKeys.remoteJobCreating.negate(), + ChatContextKeys.currentlyEditing.negate(), ), order: 4, group: 'navigation', diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index c4f3d740741b7..bac6879be0dd4 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { URI } from '../../../../../base/common/uri.js'; -import { localize2 } from '../../../../../nls.js'; +import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.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 { isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; @@ -38,6 +41,7 @@ export class ChatQueueMessageAction extends Action2 { super({ id: ChatQueueMessageAction.ID, title: localize2('chat.queueMessage', "Add to Queue"), + tooltip: localize('chat.queueMessage.tooltip', "Queue this message to send after the current request completes"), icon: Codicon.add, f1: false, category: CHAT_CATEGORY, @@ -46,6 +50,15 @@ export class ChatQueueMessageAction extends Action2 { ChatContextKeys.requestInProgress, ChatContextKeys.inputHasText ), + keybinding: { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + ChatContextKeys.requestInProgress, + queueingEnabledCondition + ), + primary: KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + 1 + }, menu: [{ id: MenuId.ChatExecuteQueue, group: 'navigation', @@ -77,6 +90,7 @@ export class ChatSteerWithMessageAction extends Action2 { super({ id: ChatSteerWithMessageAction.ID, title: localize2('chat.steerWithMessage', "Steer with Message"), + tooltip: localize('chat.steerWithMessage.tooltip', "Send this message at the next opportunity, signaling the current request to yield"), icon: Codicon.arrowRight, f1: false, category: CHAT_CATEGORY, @@ -85,6 +99,15 @@ export class ChatSteerWithMessageAction extends Action2 { ChatContextKeys.requestInProgress, ChatContextKeys.inputHasText ), + keybinding: { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + ChatContextKeys.requestInProgress, + queueingEnabledCondition + ), + primary: KeyMod.Alt | KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + 1 + }, menu: [{ id: MenuId.ChatExecuteQueue, group: 'navigation', @@ -119,17 +142,131 @@ export class ChatRemovePendingRequestAction extends Action2 { icon: Codicon.close, f1: false, category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatMessageTitle, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.isRequest, + ChatContextKeys.isPendingRequest + ) + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const chatService = accessor.get(IChatService); + const [context] = args; + + // Support both toolbar context (IChatRequestViewModel) and command context (IChatRemovePendingRequestContext) + if (isRequestVM(context) && context.pendingKind) { + chatService.removePendingRequest(context.sessionResource, context.id); + return; + } + + if (isRemovePendingRequestContext(context)) { + chatService.removePendingRequest(context.sessionResource, context.pendingRequestId); + return; + } + } +} + +export class ChatSendPendingImmediatelyAction extends Action2 { + static readonly ID = 'workbench.action.chat.sendPendingImmediately'; + + constructor() { + super({ + id: ChatSendPendingImmediatelyAction.ID, + title: localize2('chat.sendPendingImmediately', "Send Immediately"), + icon: Codicon.arrowUp, + f1: false, + category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatMessageTitle, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.isRequest, + ChatContextKeys.isPendingRequest + ) + }] }); } override run(accessor: ServicesAccessor, ...args: unknown[]): void { const chatService = accessor.get(IChatService); + const widgetService = accessor.get(IChatWidgetService); const [context] = args; - if (!isRemovePendingRequestContext(context)) { + + if (!isRequestVM(context) || !context.pendingKind) { return; } - chatService.removePendingRequest(context.sessionResource, context.pendingRequestId); + const widget = widgetService.getWidgetBySessionResource(context.sessionResource); + const model = widget?.viewModel?.model; + if (!model) { + return; + } + + const pendingRequests = model.getPendingRequests(); + const targetIndex = pendingRequests.findIndex(r => r.request.id === context.id); + if (targetIndex === -1) { + return; + } + + // Keep the target item's kind (queued vs steering) + const targetRequest = pendingRequests[targetIndex]; + + // Reorder: move target to front, keep others in their relative order + const reordered = [ + { requestId: targetRequest.request.id, kind: targetRequest.kind }, + ...pendingRequests.filter((_, i) => i !== targetIndex).map(r => ({ requestId: r.request.id, kind: r.kind })) + ]; + + chatService.setPendingRequests(context.sessionResource, reordered); + chatService.cancelCurrentRequestForSession(context.sessionResource); + chatService.processPendingRequests(context.sessionResource); + } +} + +export class ChatRemoveAllPendingRequestsAction extends Action2 { + static readonly ID = 'workbench.action.chat.removeAllPendingRequests'; + + constructor() { + super({ + id: ChatRemoveAllPendingRequestsAction.ID, + title: localize2('chat.removeAllPendingRequests', "Remove All Queued"), + icon: Codicon.clearAll, + f1: false, + category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatContext, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.hasPendingRequests + ) + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const chatService = accessor.get(IChatService); + const widgetService = accessor.get(IChatWidgetService); + const [context] = args; + + const widget = (isRequestVM(context) && widgetService.getWidgetBySessionResource(context.sessionResource)) || widgetService.lastFocusedWidget; + const model = widget?.viewModel?.model; + if (!model) { + return; + } + + for (const pendingRequest of [...model.getPendingRequests()]) { + chatService.removePendingRequest(model.sessionResource, pendingRequest.request.id); + } } } @@ -137,6 +274,8 @@ export function registerChatQueueActions(): void { registerAction2(ChatQueueMessageAction); registerAction2(ChatSteerWithMessageAction); registerAction2(ChatRemovePendingRequestAction); + registerAction2(ChatSendPendingImmediatelyAction); + registerAction2(ChatRemoveAllPendingRequestsAction); // 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 @@ -150,7 +289,7 @@ export function registerChatQueueActions(): void { ChatContextKeys.inputHasText ), group: 'navigation', - order: 3, + order: 4, isSplitButton: { togglePrimaryAction: true } }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index 88a46efec17ca..ac9da62529bc8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -340,10 +340,39 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { warningMessage = localize('chatTookLongWarning', "Chat took too long to get ready. Please ensure you are signed in to {0} and that the extension `{1}` is installed and enabled. Click restart to try again if this issue persists.", defaultChat.provider.default.name, defaultChat.chatExtensionId); } + // Compute language model diagnostic info + const languageModelIds = languageModelsService.getLanguageModelIds(); + let languageModelDefaultCount = 0; + for (const id of languageModelIds) { + const model = languageModelsService.lookupLanguageModel(id); + if (model?.isDefaultForLocation[ChatAgentLocation.Chat]) { + languageModelDefaultCount++; + } + } + + // Compute agent diagnostic info + const defaultAgent = chatAgentService.getDefaultAgent(this.location, modeInfo?.kind); + const agentHasDefault = !!defaultAgent; + const agentDefaultIsCore = defaultAgent?.isCore ?? false; + const contributedDefaultAgent = chatAgentService.getContributedDefaultAgent(this.location); + const agentHasContributedDefault = !!contributedDefaultAgent; + const agentContributedDefaultIsCore = contributedDefaultAgent?.isCore ?? false; + const agentActivatedCount = chatAgentService.getActivatedAgents().length; + this.logService.warn(warningMessage, { agentActivated, agentReady, + agentHasDefault, + agentDefaultIsCore, + agentHasContributedDefault, + agentContributedDefaultIsCore, + agentActivatedCount, + agentLocation: this.location, + agentModeKind: modeInfo?.kind, languageModelReady, + languageModelCount: languageModelIds.length, + languageModelDefaultCount, + languageModelHasRequestedModel: !!requestModel.modelId, toolsModelReady }); @@ -352,7 +381,17 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { comment: 'Provides insight into chat setup timeouts.'; agentActivated: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent was activated.' }; agentReady: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent was ready.' }; + agentHasDefault: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether a default agent exists for the location and mode.' }; + agentDefaultIsCore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the default agent is a core agent.' }; + agentHasContributedDefault: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether a contributed default agent exists for the location.' }; + agentContributedDefaultIsCore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the contributed default agent is a core agent.' }; + agentActivatedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of activated agents at timeout.' }; + agentLocation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat agent location.' }; + agentModeKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat mode kind.' }; languageModelReady: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the language model was ready.' }; + languageModelCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of registered language models at timeout.' }; + languageModelDefaultCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of language models with isDefaultForLocation[Chat] set.' }; + languageModelHasRequestedModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether a specific model ID was requested.' }; toolsModelReady: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the tools model was ready.' }; isRemote: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether this is a remote scenario.' }; isAnonymous: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether anonymous access is enabled.' }; @@ -361,7 +400,17 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { type ChatSetupTimeoutEvent = { agentActivated: boolean; agentReady: boolean; + agentHasDefault: boolean; + agentDefaultIsCore: boolean; + agentHasContributedDefault: boolean; + agentContributedDefaultIsCore: boolean; + agentActivatedCount: number; + agentLocation: string; + agentModeKind: string; languageModelReady: boolean; + languageModelCount: number; + languageModelDefaultCount: number; + languageModelHasRequestedModel: boolean; toolsModelReady: boolean; isRemote: boolean; isAnonymous: boolean; @@ -369,10 +418,21 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { }; const chatViewPane = this.viewsService.getActiveViewWithId(ChatViewId) as ChatViewPane | undefined; const matchingWelcomeView = chatViewPane?.getMatchingWelcomeView(); + this.telemetryService.publicLog2('chatSetup.timeout', { agentActivated, agentReady, + agentHasDefault, + agentDefaultIsCore, + agentHasContributedDefault, + agentContributedDefaultIsCore, + agentActivatedCount, + agentLocation: this.location, + agentModeKind: modeInfo?.kind ?? '', languageModelReady, + languageModelCount: languageModelIds.length, + languageModelDefaultCount, + languageModelHasRequestedModel: !!requestModel.modelId, toolsModelReady, isRemote: !!this.environmentService.remoteAuthority, isAnonymous: this.chatEntitlementService.anonymous, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 53368319cefc6..383d91fa9f059 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -648,6 +648,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.CheckpointsEnabled) && (this.rendererOptions.restorable ?? true); + const isPendingRequest = isRequestVM(element) && !!element.pendingKind; - templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || !(checkpointEnabled)); + templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || isPendingRequest || !(checkpointEnabled)); - // Only show restore container when we have a checkpoint and not editing - const shouldShowRestore = this.viewModel?.model.checkpoint && !this.viewModel?.editing && (index === this.delegate.getListLength() - 1); + // Only show restore container when we have a checkpoint and not editing, and not a pending request + const shouldShowRestore = this.viewModel?.model.checkpoint && !this.viewModel?.editing && (index === this.delegate.getListLength() - 1) && !isPendingRequest; templateData.checkpointRestoreContainer.classList.toggle('hidden', !(shouldShowRestore && checkpointEnabled)); const editing = element.id === this.viewModel?.editing?.id; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 8b989d713acf7..6920f80ebb2b8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -31,6 +31,7 @@ import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ITextResourceEditorInput } from '../../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -256,6 +257,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly _lockedToCodingAgentContextKey: IContextKey; private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; + private readonly _hasPendingRequestsContextKey: IContextKey; private _attachmentCapabilities: IChatAgentAttachmentCapabilities = supportsAllAttachments; // Cache for prompt file descriptions to avoid async calls during rendering @@ -342,6 +344,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IChatService private readonly chatService: IChatService, @@ -369,6 +372,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService); this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService); + this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService); this.viewContext = viewContext ?? {}; @@ -1804,6 +1808,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.viewModel = undefined; this.onDidChangeItems(); + this._hasPendingRequestsContextKey.set(false); return; } @@ -1867,6 +1872,12 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); })); this._sessionIsEmptyContextKey.set(model.getRequests().length === 0); + const updatePendingRequestKeys = () => { + const pendingCount = model.getPendingRequests().length; + this._hasPendingRequestsContextKey.set(pendingCount > 0); + }; + updatePendingRequestKeys(); + this.viewModelDisposables.add(model.onDidChangePendingRequests(() => updatePendingRequestKeys())); this.refreshParsedInput(); this.viewModelDisposables.add(model.onDidChange((e) => { @@ -2056,10 +2067,6 @@ export class ChatWidget extends Disposable implements IChatWidget { } private async _acceptInput(query: { query: string } | undefined, options: IChatAcceptInputOptions = {}): Promise { - if (this.viewModel?.model.requestInProgress.get()) { - options.queue ??= ChatRequestQueueKind.Queued; - } - if (!query && this.input.generating) { // if the user submits the input and generation finishes quickly, just submit it for them const generatingAutoSubmitWindow = 500; @@ -2099,10 +2106,26 @@ export class ChatWidget extends Disposable implements IChatWidget { const isUserQuery = !query; if (this.viewModel?.editing) { + const editingPendingRequest = this.viewModel.editing.pendingKind; + if (editingPendingRequest !== undefined) { + const editingRequestId = this.viewModel.editing!.id; + this.chatService.removePendingRequest(this.viewModel.sessionResource, editingRequestId); + options.queue ??= editingPendingRequest; + } + this.finishedEditing(true); this.viewModel.model?.setCheckpoint(undefined); } + const model = this.viewModel.model; + const requestInProgress = model.requestInProgress.get(); + if (requestInProgress) { + options.queue ??= ChatRequestQueueKind.Queued; + } + if (!requestInProgress && !(await this.confirmPendingRequestsBeforeSend(model, options))) { + return; + } + // process the prompt command await this._applyPromptFileIfSet(requestInputs); await this._autoAttachInstructions(requestInputs); @@ -2203,6 +2226,46 @@ export class ChatWidget extends Disposable implements IChatWidget { return sent.data.responseCreatedPromise; } + private async confirmPendingRequestsBeforeSend(model: IChatModel, options: IChatAcceptInputOptions): Promise { + if (options.queue) { + return true; + } + + const hasPendingRequests = model.getPendingRequests().length > 0; + if (!hasPendingRequests) { + return true; + } + + const promptResult = await this.dialogService.prompt({ + type: 'question', + message: localize('chat.pendingRequests.prompt.message', "You already have pending requests."), + detail: localize('chat.pendingRequests.prompt.detail', "Do you want to keep them in the queue or remove them before sending this message?"), + buttons: [ + { + label: localize('chat.pendingRequests.prompt.keep', "Keep Pending Requests"), + run: () => 'keep' + }, + { + label: localize('chat.pendingRequests.prompt.remove', "Remove Pending Requests"), + run: () => 'remove' + } + ], + cancelButton: true + }); + + if (!promptResult.result) { + return false; + } + + if (promptResult.result === 'remove') { + for (const pendingRequest of [...model.getPendingRequests()]) { + this.chatService.removePendingRequest(model.sessionResource, pendingRequest.request.id); + } + } + + return true; + } + getModeRequestOptions(): Partial { return { modeInfo: this.input.currentModeInfo, 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 887cbf1bf027a..d94ccd6dbacf7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2537,7 +2537,6 @@ have to be updated for changes to the rules above, or to support more deeply nes top: -13px; right: 20px; border-radius: 3px; - width: 28px; height: 26px; } @@ -2554,7 +2553,6 @@ have to be updated for changes to the rules above, or to support more deeply nes } .request-hover:not(.expanded) .actions-container { - width: 22px; height: 22px; } @@ -2563,11 +2561,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } .request-hover:not(.expanded) .actions-container { - - .action-label.codicon-discard, - .action-label.codicon-x, - .action-label.codicon-edit { - margin-top: 4px; + .action-label { + margin: 4px 2px 0; padding: 3px 3px; } } @@ -2859,6 +2854,10 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Pending request styles */ .interactive-item-container.pending-request { opacity: 0.7; + + .request-hover { + top: -17px !important; + } } .interactive-item-container .chat-request-status { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 2c31f62bdd0d4..4331b69fa59ba 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -23,6 +23,7 @@ export namespace ChatContextKeys { export const isResponse = new RawContextKey('chatResponse', false, { type: 'boolean', description: localize('chatResponse', "The chat item is a response.") }); export const isRequest = new RawContextKey('chatRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") }); + export const isPendingRequest = new RawContextKey('chatRequestIsPending', false, { type: 'boolean', description: localize('chatRequestIsPending', "True when the chat request item is pending in the queue.") }); export const itemId = new RawContextKey('chatItemId', '', { type: 'string', description: localize('chatItemId', "The id of the chat item.") }); export const lastItemId = new RawContextKey('chatLastItemId', [], { type: 'string', description: localize('chatLastItemId', "The id of the last chat item.") }); @@ -70,7 +71,6 @@ export namespace ChatContextKeys { 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 08a63e50dbd12..ff9fdf7e038b5 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1332,6 +1332,13 @@ export interface IChatService { * Adding new requests should go through sendRequest with the queue option. */ setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void; + /** + * Ensures pending requests for the session are processing. If restoring from + * storage or after an error, pending requests may be present without an + * active chat message 'loop' happening. THis triggers the loop to happen + * as needed. Idempotent, safe to call at any time. + */ + processPendingRequests(sessionResource: URI): 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 f879497b4df49..2392646264561 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, ChatRequestQueueKind, ChatSendResult, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, 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'; @@ -51,6 +51,7 @@ import { ILanguageModelToolsService } from '../tools/languageModelToolsService.j import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; +import { IHooksExecutionService } from '../hooksExecutionService.js'; const serializedChatKey = 'interactive.sessions'; @@ -155,6 +156,7 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, + @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, ) { super(); @@ -729,6 +731,34 @@ export class ChatService extends Disposable implements IChatService { await this._sendRequestAsync(model, model.sessionResource, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise; } + private queuePendingRequest(model: ChatModel, sessionResource: URI, request: string, options: IChatSendRequestOptions): ChatSendResultQueued { + const location = options.location ?? model.initialLocation; + const parsedRequest = this.parseChatRequest(sessionResource, request, location, options); + const requestModel = new ChatRequestModel({ + session: model, + message: parsedRequest, + variableData: { variables: [] }, + timestamp: Date.now(), + modeInfo: options.modeInfo, + locationData: options.locationData, + attachedContext: options.attachedContext, + modelId: options.userSelectedModelId, + userSelectedTools: options.userSelectedTools?.get(), + }); + + const deferred = new DeferredPromise(); + this._queuedRequestDeferreds.set(requestModel.id, deferred); + + model.addPendingRequest(requestModel, options.queue ?? ChatRequestQueueKind.Queued, { ...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 }; + } + async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); @@ -743,41 +773,25 @@ export class ChatService extends Disposable implements IChatService { throw new Error(`Unknown session: ${sessionResource}`); } - if (this._pendingRequests.has(sessionResource)) { + const hasPendingRequest = this._pendingRequests.has(sessionResource); + const hasPendingQueue = model.getPendingRequests().length > 0; + + if (hasPendingRequest) { // 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 }; + return this.queuePendingRequest(model, sessionResource, request, options); } this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); return { kind: 'rejected', reason: 'Request already in progress' }; } + if (options?.queue && hasPendingQueue) { + const queued = this.queuePendingRequest(model, sessionResource, request, options); + this.processNextPendingRequest(model); + return queued; + } + const requests = model.getRequests(); for (let i = requests.length - 1; i >= 0; i -= 1) { const request = requests[i]; @@ -900,6 +914,10 @@ export class ChatService extends Disposable implements IChatService { this.logService.warn('[ChatService] Failed to collect hooks:', error); } + if (collectedHooks) { + store.add(this.hooksExecutionService.registerHooks(model.sessionResource, collectedHooks)); + } + const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); @@ -1088,6 +1106,7 @@ export class ChatService extends Disposable implements IChatService { completeResponseCreated(); this.trace('sendRequest', `Provider returned response for session ${model.sessionResource}`); + shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; request.response?.complete(); if (agentOrCommandFollowups) { agentOrCommandFollowups.then(followups => { @@ -1117,13 +1136,16 @@ export class ChatService extends Disposable implements IChatService { store.dispose(); } }; + let shouldProcessPending = false; const rawResponsePromise = sendRequestInternal(); // Note- requestId is not known at this point, assigned later 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); + if (shouldProcessPending) { + this.processNextPendingRequest(model); + } }); this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource }); return { @@ -1132,6 +1154,13 @@ export class ChatService extends Disposable implements IChatService { }; } + processPendingRequests(sessionResource: URI): void { + const model = this._sessionModels.get(sessionResource); + if (model && !this._pendingRequests.has(sessionResource)) { + this.processNextPendingRequest(model); + } + } + /** * Process the next pending request from the model's queue, if any. * Called after a request completes to continue processing queued requests. @@ -1144,8 +1173,8 @@ export class ChatService extends Disposable implements IChatService { this.trace('processNextPendingRequest', `Processing queued request for session ${model.sessionResource}`); - const deferred = this._queuedRequestDeferreds.get(pendingRequest.id); - this._queuedRequestDeferreds.delete(pendingRequest.id); + const deferred = this._queuedRequestDeferreds.get(pendingRequest.request.id); + this._queuedRequestDeferreds.delete(pendingRequest.request.id); const sendOptions = pendingRequest.sendOptions; const location = sendOptions.location ?? sendOptions.locationData?.type ?? model.initialLocation; diff --git a/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts index 37f5ef5779e3c..d79d2f59199e2 100644 --- a/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts +++ b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts @@ -5,7 +5,17 @@ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { URI } from '../../../../base/common/uri.js'; -import { HookTypeValue } from './promptSyntax/hookSchema.js'; +import { HookTypeValue, IChatRequestHooks, IHookCommand } from './promptSyntax/hookSchema.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; +import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../services/output/common/output.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { localize } from '../../../../nls.js'; + +export const hooksOutputChannelId = 'hooksExecution'; +const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); export const enum HookResultKind { Success = 1, @@ -19,6 +29,7 @@ export interface IHookResult { export interface IHooksExecutionOptions { readonly input?: unknown; + readonly token?: CancellationToken; } /** @@ -26,7 +37,7 @@ export interface IHooksExecutionOptions { * MainThreadHooks implements this to forward calls to the extension host. */ export interface IHooksExecutionProxy { - executeHook(hookType: HookTypeValue, sessionResource: URI, input: unknown): Promise; + runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise; } export const IHooksExecutionService = createDecorator('hooksExecutionService'); @@ -39,6 +50,16 @@ export interface IHooksExecutionService { */ setProxy(proxy: IHooksExecutionProxy): void; + /** + * Register hooks for a session. Returns a disposable that unregisters them. + */ + registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable; + + /** + * Get hooks registered for a session. + */ + getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined; + /** * Execute hooks of the given type for the given session */ @@ -49,16 +70,118 @@ export class HooksExecutionService implements IHooksExecutionService { declare readonly _serviceBrand: undefined; private _proxy: IHooksExecutionProxy | undefined; + private readonly _sessionHooks = new Map(); + private _channelRegistered = false; + private _requestCounter = 0; + + constructor( + @ILogService private readonly _logService: ILogService, + @IOutputService private readonly _outputService: IOutputService, + ) { } setProxy(proxy: IHooksExecutionProxy): void { this._proxy = proxy; } + private _ensureOutputChannel(): void { + if (this._channelRegistered) { + return; + } + Registry.as(Extensions.OutputChannels).registerChannel({ + id: hooksOutputChannelId, + label: hooksOutputChannelLabel, + log: false + }); + this._channelRegistered = true; + } + + private _log(requestId: number, hookType: HookTypeValue, message: string): void { + this._ensureOutputChannel(); + const channel = this._outputService.getChannel(hooksOutputChannelId); + if (channel) { + channel.append(`[${new Date().toISOString()}] [#${requestId}] [${hookType}] ${message}\n`); + } + } + + private async _runSingleHook( + requestId: number, + hookType: HookTypeValue, + hookCommand: IHookCommand, + input: unknown, + token: CancellationToken + ): Promise { + const hookCommandJson = JSON.stringify({ + ...hookCommand, + cwd: hookCommand.cwd?.fsPath + }); + this._log(requestId, hookType, `Running: ${hookCommandJson}`); + if (input !== undefined) { + this._log(requestId, hookType, `Input: ${JSON.stringify(input)}`); + } + + const sw = StopWatch.create(); + try { + const result = await this._proxy!.runHookCommand(hookCommand, input, token); + this._logResult(requestId, hookType, result, sw.elapsed()); + return result; + } catch (err) { + const errMessage = err instanceof Error ? err.message : String(err); + this._log(requestId, hookType, `Error in ${sw.elapsed()}ms: ${errMessage}`); + return { kind: HookResultKind.Error, result: errMessage }; + } + } + + private _logResult(requestId: number, hookType: HookTypeValue, result: IHookResult, elapsed: number): void { + const resultKindStr = result.kind === HookResultKind.Success ? 'Success' : 'Error'; + const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]'; + if (hasOutput) { + this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`); + this._log(requestId, hookType, `Output: ${resultStr}`); + } else { + this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`); + } + } + + registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { + const key = sessionResource.toString(); + this._sessionHooks.set(key, hooks); + return toDisposable(() => { + this._sessionHooks.delete(key); + }); + } + + getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { + return this._sessionHooks.get(sessionResource.toString()); + } + async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise { if (!this._proxy) { return []; } - return this._proxy.executeHook(hookType, sessionResource, options?.input); + const hooks = this.getHooksForSession(sessionResource); + if (!hooks) { + return []; + } + + const hookCommands = hooks[hookType]; + if (!hookCommands || hookCommands.length === 0) { + return []; + } + + const requestId = this._requestCounter++; + const token = options?.token ?? CancellationToken.None; + + this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); + this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`); + + const results: IHookResult[] = []; + for (const hookCommand of hookCommands) { + const result = await this._runSingleHook(requestId, hookType, hookCommand, options?.input, token); + results.push(result); + } + + return results; } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index c27ff320162f2..056abf3a60f02 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -14,7 +14,7 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; -import { IObservable, autorun, autorunSelfDisposable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js'; +import { IObservable, autorun, autorunSelfDisposable, constObservable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js'; import { basename, isEqual } from '../../../../../base/common/resources.js'; import { hasKey, WithDefinedProps } from '../../../../../base/common/types.js'; import { URI, UriDto } from '../../../../../base/common/uri.js'; @@ -43,7 +43,6 @@ 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; /** @@ -53,6 +52,35 @@ export interface IChatPendingRequest { readonly sendOptions: IChatSendRequestOptions; } +/** + * Serializable version of IChatSendRequestOptions for pending requests. + * Excludes observables and non-serializable fields. + */ +export interface ISerializableSendOptions { + modeInfo?: IChatRequestModeInfo; + userSelectedModelId?: string; + /** Static snapshot of user-selected tools (not an observable) */ + userSelectedTools?: UserSelectedTools; + location?: ChatAgentLocation; + locationData?: IChatLocationData; + attempt?: number; + noCommandDetection?: boolean; + agentId?: string; + agentIdSilent?: string; + slashCommand?: string; + confirmation?: string; +} + +/** + * Serializable representation of a pending chat request. + */ +export interface ISerializablePendingRequestData { + id: string; + request: ISerializableChatRequestData; + kind: ChatRequestQueueKind; + sendOptions: ISerializableSendOptions; +} + export const CHAT_ATTACHABLE_IMAGE_MIME_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', @@ -1479,6 +1507,8 @@ export interface ISerializableChatData3 extends Omit [p.id, p])); + const existingMap = new Map(this._pendingRequests.map(p => [p.request.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 }); + newPending.push(existing.kind === kind ? existing : { request: existing.request, kind, sendOptions: existing.sendOptions }); } } this._pendingRequests.length = 0; @@ -1845,7 +1875,6 @@ export class ChatModel extends Disposable implements IChatModel { */ addPendingRequest(request: ChatRequestModel, kind: ChatRequestQueueKind, sendOptions: IChatSendRequestOptions): IChatPendingRequest { const pendingRequest: IChatPendingRequest = { - id: request.id, request, kind, sendOptions, @@ -1875,7 +1904,7 @@ export class ChatModel extends Disposable implements IChatModel { * @internal Used by ChatService to remove a pending request */ removePendingRequest(id: string): void { - const index = this._pendingRequests.findIndex(r => r.id === id); + const index = this._pendingRequests.findIndex(r => r.request.id === id); if (index !== -1) { this._pendingRequests.splice(index, 1); this._onDidChangePendingRequests.fire(); @@ -2049,6 +2078,11 @@ export class ChatModel extends Disposable implements IChatModel { this._repoData = isValidFullData && initialData.repoData ? initialData.repoData : undefined; + // Hydrate pending requests from serialized data + if (isValidFullData && initialData.pendingRequests) { + this._pendingRequests = this._deserializePendingRequests(initialData.pendingRequests); + } + this._initialLocation = initialData?.initialLocation ?? initialModelProps.initialLocation; this._canUseTools = initialModelProps.canUseTools; @@ -2152,72 +2186,74 @@ export class ChatModel extends Disposable implements IChatModel { } try { - return requests.map((raw: ISerializableChatRequestData) => { - const parsedRequest = - typeof raw.message === 'string' - ? this.getParsedRequestFromString(raw.message) - : reviveParsedChatRequest(raw.message); - - // Old messages don't have variableData, or have it in the wrong (non-array) shape - const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); - const request = new ChatRequestModel({ - session: this, - message: parsedRequest, - variableData, - timestamp: raw.timestamp ?? -1, - restoredId: raw.requestId, - confirmation: raw.confirmation, - editedFileEvents: raw.editedFileEvents, - modelId: raw.modelId, - }); - request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; - // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts - if (raw.response || raw.result || (raw as any).responseErrorDetails) { - const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format - reviveSerializedAgent(raw.agent) : undefined; - - // Port entries from old format - const result = 'responseErrorDetails' in raw ? - // eslint-disable-next-line local/code-no-dangerous-type-assertions - { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; - let modelState = raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() }; - if (modelState.value === ResponseModelState.Pending || modelState.value === ResponseModelState.NeedsInput) { - modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; - } - - request.response = new ChatResponseModel({ - responseContent: raw.response ?? [new MarkdownString(raw.response)], - session: this, - agent, - slashCommand: raw.slashCommand, - requestId: request.id, - modelState, - vote: raw.vote, - timestamp: raw.timestamp, - voteDownReason: raw.voteDownReason, - result, - followups: raw.followups, - restoredId: raw.responseId, - timeSpentWaiting: raw.timeSpentWaiting, - shouldBeBlocked: request.shouldBeBlocked.get(), - codeBlockInfos: raw.responseMarkdownInfo?.map(info => ({ suggestionId: info.suggestionId })), - }); - request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; - if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? - request.response.applyReference(revive(raw.usedContext)); - } - - raw.contentReferences?.forEach(r => request.response!.applyReference(revive(r))); - raw.codeCitations?.forEach(c => request.response!.applyCodeCitation(revive(c))); - } - return request; - }); + return requests.map(r => this._deserializeRequest(r)); } catch (error) { this.logService.error('Failed to parse chat data', error); return []; } } + private _deserializeRequest(raw: ISerializableChatRequestData): ChatRequestModel { + const parsedRequest = + typeof raw.message === 'string' + ? this.getParsedRequestFromString(raw.message) + : reviveParsedChatRequest(raw.message); + + // Old messages don't have variableData, or have it in the wrong (non-array) shape + const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); + const request = new ChatRequestModel({ + session: this, + message: parsedRequest, + variableData, + timestamp: raw.timestamp ?? -1, + restoredId: raw.requestId, + confirmation: raw.confirmation, + editedFileEvents: raw.editedFileEvents, + modelId: raw.modelId, + }); + request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts + if (raw.response || raw.result || (raw as any).responseErrorDetails) { + const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format + reviveSerializedAgent(raw.agent) : undefined; + + // Port entries from old format + const result = 'responseErrorDetails' in raw ? + // eslint-disable-next-line local/code-no-dangerous-type-assertions + { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; + let modelState = raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() }; + if (modelState.value === ResponseModelState.Pending || modelState.value === ResponseModelState.NeedsInput) { + modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; + } + + request.response = new ChatResponseModel({ + responseContent: raw.response ?? [new MarkdownString(raw.response)], + session: this, + agent, + slashCommand: raw.slashCommand, + requestId: request.id, + modelState, + vote: raw.vote, + timestamp: raw.timestamp, + voteDownReason: raw.voteDownReason, + result, + followups: raw.followups, + restoredId: raw.responseId, + timeSpentWaiting: raw.timeSpentWaiting, + shouldBeBlocked: request.shouldBeBlocked.get(), + codeBlockInfos: raw.responseMarkdownInfo?.map(info => ({ suggestionId: info.suggestionId })), + }); + request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; + if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? + request.response.applyReference(revive(raw.usedContext)); + } + + raw.contentReferences?.forEach(r => request.response!.applyReference(revive(r))); + raw.codeCitations?.forEach(c => request.response!.applyCodeCitation(revive(c))); + } + return request; + } + private reviveVariableData(raw: IChatRequestVariableData): IChatRequestVariableData { const variableData = raw && Array.isArray(raw.variables) ? raw : @@ -2237,6 +2273,29 @@ export class ChatModel extends Disposable implements IChatModel { }; } + /** + * Hydrates pending requests from serialized data. + * For each serialized pending request, finds the matching request model and adds it to the pending queue. + */ + private _deserializePendingRequests(pendingRequests: ISerializablePendingRequestData[]): IChatPendingRequest[] { + try { + return pendingRequests.map(pending => ({ + id: pending.id, + request: this._deserializeRequest(pending.request), + kind: pending.kind, + sendOptions: { + ...pending.sendOptions, + userSelectedTools: pending.sendOptions.userSelectedTools + ? constObservable(pending.sendOptions.userSelectedTools) + : undefined, + } + })); + } catch (e) { + this.logService.error('Failed to parse pending chat requests', e); + return []; + } + } + getRequests(): ChatRequestModel[] { @@ -2553,6 +2612,26 @@ export function getCodeCitationsMessage(citations: ReadonlyArray i.contrib, objectsEqual), }); +const pendingRequestSchema = Adapt.object({ + id: Adapt.t(p => p.request.id, Adapt.key()), + request: Adapt.t(p => p.request, requestSchema), + kind: Adapt.v(p => p.kind), + sendOptions: Adapt.v(p => serializeSendOptions(p.sendOptions), objectsEqual), +}); + export const storageSchema = Adapt.object({ version: Adapt.v(() => 3), creationDate: Adapt.v(m => m.timestamp), @@ -170,6 +177,7 @@ export const storageSchema = Adapt.object({ requests: Adapt.t(m => m.getRequests(), Adapt.array(requestSchema)), hasPendingEdits: Adapt.v(m => m.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)), repoData: Adapt.v(m => m.repoData, objectsEqual), + pendingRequests: Adapt.t(m => m.getPendingRequests(), Adapt.array(pendingRequestSchema)), }); export class ChatSessionOperationLog extends Adapt.ObjectMutationLog implements IChatDataSerializerLog { 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 104a1bd528f08..4054723540999 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 @@ -62,6 +62,10 @@ class MockChatService implements IChatService { } + processPendingRequests(sessionResource: URI): void { + + } + setLiveSessionItems(items: IChatDetail[]): void { this.liveSessionItems = items; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index e9d2d91f3acef..3b884414dec0b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -45,6 +45,7 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { IHooksExecutionService } from '../../../common/hooksExecutionService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; @@ -87,6 +88,9 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); + collection.set(IHooksExecutionService, new class extends mock() { + override registerHooks() { return Disposable.None; } + }); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; 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 2faa8332a53ac..68c2d724b002c 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -67,6 +67,9 @@ export class MockChatService implements IChatService { } appendProgress(request: IChatRequestModel, progress: IChatProgress): void { + } + processPendingRequests(sessionResource: URI): void { + } /** * Returns whether the request was accepted. diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts new file mode 100644 index 0000000000000..35e0dc6bc92d0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { HookResultKind, HooksExecutionService, IHookResult, IHooksExecutionProxy } from '../../common/hooksExecutionService.js'; +import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; +import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js'; + +function cmd(command: string): IHookCommand { + return { type: 'command', command, cwd: URI.file('/') }; +} + +function createMockOutputService(): IOutputService { + const mockChannel: Partial = { + append: () => { }, + }; + return { + _serviceBrand: undefined, + getChannel: () => mockChannel as IOutputChannel, + } as unknown as IOutputService; +} + +suite('HooksExecutionService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let service: HooksExecutionService; + const sessionUri = URI.file('/test/session'); + + setup(() => { + service = new HooksExecutionService(new NullLogService(), createMockOutputService()); + }); + + suite('registerHooks', () => { + test('registers hooks for a session', () => { + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + assert.strictEqual(service.getHooksForSession(sessionUri), hooks); + }); + + test('returns disposable that unregisters hooks', () => { + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + const disposable = service.registerHooks(sessionUri, hooks); + + assert.strictEqual(service.getHooksForSession(sessionUri), hooks); + + disposable.dispose(); + + assert.strictEqual(service.getHooksForSession(sessionUri), undefined); + }); + + test('different sessions have independent hooks', () => { + const session1 = URI.file('/test/session1'); + const session2 = URI.file('/test/session2'); + const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] }; + const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] }; + + store.add(service.registerHooks(session1, hooks1)); + store.add(service.registerHooks(session2, hooks2)); + + assert.strictEqual(service.getHooksForSession(session1), hooks1); + assert.strictEqual(service.getHooksForSession(session2), hooks2); + }); + }); + + suite('getHooksForSession', () => { + test('returns undefined for unregistered session', () => { + assert.strictEqual(service.getHooksForSession(sessionUri), undefined); + }); + }); + + suite('executeHook', () => { + test('returns empty array when no proxy set', async () => { + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + assert.deepStrictEqual(results, []); + }); + + test('returns empty array when no hooks registered for session', async () => { + const proxy = createMockProxy(); + service.setProxy(proxy); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + assert.deepStrictEqual(results, []); + }); + + test('returns empty array when no hooks of requested type', async () => { + const proxy = createMockProxy(); + service.setProxy(proxy); + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PostToolUse, sessionUri); + assert.deepStrictEqual(results, []); + }); + + test('executes hook commands via proxy and returns results', async () => { + const proxyResults: IHookResult[] = []; + const proxy = createMockProxy((cmd) => { + const result: IHookResult = { kind: HookResultKind.Success, result: `executed: ${cmd.command}` }; + proxyResults.push(result); + return result; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, HookResultKind.Success); + assert.strictEqual(results[0].result, 'executed: echo test'); + }); + + test('executes multiple hook commands in order', async () => { + const executedCommands: string[] = []; + const proxy = createMockProxy((cmd) => { + executedCommands.push(cmd.command ?? ''); + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { + [HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')] + }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(results.length, 3); + assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']); + }); + + test('wraps proxy errors in HookResultKind.Error', async () => { + const proxy = createMockProxy(() => { + throw new Error('proxy failed'); + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('fail')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, HookResultKind.Error); + assert.strictEqual(results[0].result, 'proxy failed'); + }); + + test('passes cancellation token to proxy', async () => { + let receivedToken: CancellationToken | undefined; + const proxy = createMockProxy((_cmd, _input, token) => { + receivedToken = token; + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const cts = store.add(new CancellationTokenSource()); + await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token }); + + assert.strictEqual(receivedToken, cts.token); + }); + + test('uses CancellationToken.None when no token provided', async () => { + let receivedToken: CancellationToken | undefined; + const proxy = createMockProxy((_cmd, _input, token) => { + receivedToken = token; + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(receivedToken, CancellationToken.None); + }); + + test('passes input to proxy', async () => { + let receivedInput: unknown; + const proxy = createMockProxy((_cmd, input) => { + receivedInput = input; + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const testInput = { foo: 'bar', nested: { value: 123 } }; + await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput }); + + assert.deepStrictEqual(receivedInput, testInput); + }); + }); + + function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookResult): IHooksExecutionProxy { + return { + runHookCommand: async (hookCommand, input, token) => { + if (handler) { + return handler(hookCommand, input, token); + } + return { kind: HookResultKind.Success, result: 'mock result' }; + } + }; + } +}); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index ee07a7c00d420..934d86152e6b7 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -25,9 +25,9 @@ import { TestExtensionService, TestStorageService } from '../../../../../test/co import { CellUri } from '../../../../notebook/common/notebookCommon.js'; import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, IChatRequestFileEntry, StringChatContextValue } from '../../../common/attachments/chatVariableEntries.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatModel, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatRequestModel, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; -import { IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatService } from '../chatService/mockChatService.js'; @@ -738,3 +738,222 @@ suite('ChatResponseModel', () => { } }); }); + +suite('ChatModel - Pending Requests', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + + function createModel(): ChatModel { + return testDisposables.add(instantiationService.createInstance( + ChatModel, + undefined, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + } + + function addRequestToModel(model: ChatModel, text: string): ChatRequestModel { + return model.addRequest( + { text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, + { variables: [] }, + 0 + ); + } + + setup(async () => { + instantiationService = testDisposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IExtensionService, new TestExtensionService()); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(IChatService, new MockChatService()); + }); + + test('addPendingRequest - queued messages are added at the end', () => { + const model = createModel(); + const request1 = addRequestToModel(model, 'first'); + const request2 = addRequestToModel(model, 'second'); + + model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {}); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 2); + assert.strictEqual(pending[0].request.id, request1.id); + assert.strictEqual(pending[1].request.id, request2.id); + }); + + test('addPendingRequest - steering messages are inserted before queued messages', () => { + const model = createModel(); + const queued = addRequestToModel(model, 'queued'); + const steering = addRequestToModel(model, 'steering'); + + model.addPendingRequest(queued, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(steering, ChatRequestQueueKind.Steering, {}); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 2); + assert.strictEqual(pending[0].request.id, steering.id); + assert.strictEqual(pending[0].kind, ChatRequestQueueKind.Steering); + assert.strictEqual(pending[1].request.id, queued.id); + assert.strictEqual(pending[1].kind, ChatRequestQueueKind.Queued); + }); + + test('addPendingRequest - multiple steering messages maintain order', () => { + const model = createModel(); + const [steering1, steering2, queued] = ['s1', 's2', 'q'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(queued, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(steering1, ChatRequestQueueKind.Steering, {}); + model.addPendingRequest(steering2, ChatRequestQueueKind.Steering, {}); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 3); + assert.strictEqual(pending[0].request.id, steering1.id); + assert.strictEqual(pending[1].request.id, steering2.id); + assert.strictEqual(pending[2].request.id, queued.id); + }); + + test('addPendingRequest - fires onDidChangePendingRequests event', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + + let eventFired = false; + testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; })); + + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + assert.strictEqual(eventFired, true); + }); + + test('removePendingRequest - removes specified request', () => { + const model = createModel(); + const [request1, request2] = ['r1', 'r2'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {}); + + model.removePendingRequest(request1.id); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 1); + assert.strictEqual(pending[0].request.id, request2.id); + }); + + test('removePendingRequest - no-op for non-existent request', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + let eventCount = 0; + testDisposables.add(model.onDidChangePendingRequests(() => { eventCount++; })); + + model.removePendingRequest('non-existent-id'); + + assert.strictEqual(model.getPendingRequests().length, 1); + assert.strictEqual(eventCount, 0); + }); + + test('dequeuePendingRequest - returns and removes first request', () => { + const model = createModel(); + const [request1, request2] = ['r1', 'r2'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {}); + + const dequeued = model.dequeuePendingRequest(); + + assert.strictEqual(dequeued?.request.id, request1.id); + assert.strictEqual(model.getPendingRequests().length, 1); + assert.strictEqual(model.getPendingRequests()[0].request.id, request2.id); + }); + + test('dequeuePendingRequest - returns undefined when empty', () => { + const model = createModel(); + assert.strictEqual(model.dequeuePendingRequest(), undefined); + }); + + test('dequeuePendingRequest - fires event when request dequeued', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + let eventFired = false; + testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; })); + + model.dequeuePendingRequest(); + + assert.strictEqual(eventFired, true); + }); + + test('clearPendingRequests - removes all pending requests', () => { + const model = createModel(); + ['r1', 'r2', 'r3'].forEach(t => { + model.addPendingRequest(addRequestToModel(model, t), ChatRequestQueueKind.Queued, {}); + }); + + model.clearPendingRequests(); + + assert.strictEqual(model.getPendingRequests().length, 0); + }); + + test('clearPendingRequests - no event when already empty', () => { + const model = createModel(); + + let eventFired = false; + testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; })); + + model.clearPendingRequests(); + + assert.strictEqual(eventFired, false); + }); + + test('setPendingRequests - reorders existing pending requests', () => { + const model = createModel(); + const [r1, r2, r3] = ['r1', 'r2', 'r3'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(r1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(r2, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(r3, ChatRequestQueueKind.Steering, {}); + + // Reverse the order + model.setPendingRequests([ + { requestId: r2.id, kind: ChatRequestQueueKind.Queued }, + { requestId: r1.id, kind: ChatRequestQueueKind.Steering }, // Change kind + ]); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 2); + assert.strictEqual(pending[0].request.id, r2.id); + assert.strictEqual(pending[1].request.id, r1.id); + assert.strictEqual(pending[1].kind, ChatRequestQueueKind.Steering); + }); + + test('setPendingRequests - ignores non-existent request IDs', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + model.setPendingRequests([ + { requestId: 'non-existent', kind: ChatRequestQueueKind.Queued }, + { requestId: request.id, kind: ChatRequestQueueKind.Queued }, + ]); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 1); + assert.strictEqual(pending[0].request.id, request.id); + }); + + test('pending requests preserve send options', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + const sendOptions = { agentId: 'test-agent', attempt: 3 }; + + const pending = model.addPendingRequest(request, ChatRequestQueueKind.Queued, sendOptions); + + assert.strictEqual(pending.sendOptions.agentId, 'test-agent'); + assert.strictEqual(pending.sendOptions.attempt, 3); + }); +}); diff --git a/src/vs/workbench/contrib/policyExport/test/node/policyExport.integrationTest.ts b/src/vs/workbench/contrib/policyExport/test/node/policyExport.integrationTest.ts index ddfa6885e0116..a405426a5950f 100644 --- a/src/vs/workbench/contrib/policyExport/test/node/policyExport.integrationTest.ts +++ b/src/vs/workbench/contrib/policyExport/test/node/policyExport.integrationTest.ts @@ -38,8 +38,10 @@ suite('PolicyExport Integration Tests', () => { ? join(rootPath, 'scripts', 'code.bat') : join(rootPath, 'scripts', 'code.sh'); + // Skip prelaunch to avoid redownloading electron while the parent VS Code is using it await exec(`"${scriptPath}" --export-policy-data="${tempFile}"`, { - cwd: rootPath + cwd: rootPath, + env: { ...process.env, VSCODE_SKIP_PRELAUNCH: '1' } }); // Read both files diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index 0ea154f88d715..a6b9475f06dca 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -229,7 +229,7 @@ export class WorkspaceChangeExtHostRelauncher extends Disposable implements IWor @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IExtensionService extensionService: IExtensionService, @IHostService hostService: IHostService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { super(); @@ -238,6 +238,10 @@ export class WorkspaceChangeExtHostRelauncher extends Disposable implements IWor return; // no restart when in tests: see https://github.com/microsoft/vscode/issues/66936 } + if (contextService.getWorkspace().isAgentSessionsWorkspace) { + return; // no restart for agent sessions workspace + } + if (environmentService.remoteAuthority) { hostService.reload(); // TODO@aeschli, workaround } else if (isNative) { diff --git a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css index 243c3aea22166..b80d8e50ea747 100644 --- a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css +++ b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css @@ -43,6 +43,7 @@ border-radius: var(--vscode-cornerRadius-large); padding: 5px; flex-shrink: 0; + background-image: url('../../../../browser/media/code-icon.svg'); background-size: contain; background-position: center; background-repeat: no-repeat; diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 270dc32d25b56..eab82efe7fcdf 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -4,14 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; -import { asCSSUrl } from '../../../../base/browser/cssValue.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { toAction } from '../../../../base/common/actions.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { FileAccess } from '../../../../base/common/network.js'; import { isWeb } from '../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import * as nls from '../../../../nls.js'; @@ -367,7 +365,6 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor const productInfo = dom.append(container, dom.$('.product-info')); const logoContainer = dom.append(productInfo, dom.$('.product-logo')); - logoContainer.style.backgroundImage = asCSSUrl(FileAccess.asBrowserUri('vs/workbench/browser/media/code-icon.svg')); logoContainer.setAttribute('role', 'img'); logoContainer.setAttribute('aria-label', this.productService.nameLong); diff --git a/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts b/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts index edc7e02d6f4d0..1c1d6308563ad 100644 --- a/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts +++ b/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IWorkspaceEditingService } from '../common/workspaceEditing.js'; +import { IDidEnterWorkspaceEvent, IWorkspaceEditingService } from '../common/workspaceEditing.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { hasWorkspaceFileExtension, isSavedWorkspace, isUntitledWorkspace, isWorkspaceIdentifier, IWorkspaceContextService, IWorkspaceIdentifier, toWorkspaceIdentifier, WorkbenchState, WORKSPACE_EXTENSION, WORKSPACE_FILTER } from '../../../../platform/workspace/common/workspace.js'; +import { hasWorkspaceFileExtension, IAnyWorkspaceIdentifier, isSavedWorkspace, isUntitledWorkspace, isWorkspaceIdentifier, IWorkspaceContextService, IWorkspaceIdentifier, toWorkspaceIdentifier, WorkbenchState, WORKSPACE_EXTENSION, WORKSPACE_FILTER } from '../../../../platform/workspace/common/workspace.js'; import { IJSONEditingService, JSONEditingError, JSONEditingErrorCode } from '../../configuration/common/jsonEditing.js'; import { IWorkspaceFolderCreationData, IWorkspacesService, rewriteWorkspaceFileForNewLocation, IEnterWorkspaceResult, IStoredWorkspace } from '../../../../platform/workspaces/common/workspaces.js'; import { WorkspaceService } from '../../configuration/browser/configurationService.js'; @@ -29,11 +29,35 @@ import { IWorkbenchConfigurationService } from '../../configuration/common/confi import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Promises } from '../../../../base/common/async.js'; + +export class DidEnterWorkspaceEvent implements IDidEnterWorkspaceEvent { + + private readonly promises: Promise[] = []; + + constructor( + readonly oldWorkspace: IAnyWorkspaceIdentifier, + readonly newWorkspace: IAnyWorkspaceIdentifier + ) { } + + join(promise: Promise): void { + this.promises.push(promise); + } + + async wait(): Promise { + await Promises.settled(this.promises); + } +} export abstract class AbstractWorkspaceEditingService extends Disposable implements IWorkspaceEditingService { declare readonly _serviceBrand: undefined; + private readonly _onDidEnterWorkspace = this._register(new Emitter()); + readonly onDidEnterWorkspace: Event = this._onDidEnterWorkspace.event; + constructor( @IJSONEditingService private readonly jsonEditingService: IJSONEditingService, @IWorkspaceContextService protected readonly contextService: WorkspaceService, @@ -51,6 +75,7 @@ export abstract class AbstractWorkspaceEditingService extends Disposable impleme @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, + @ILogService protected readonly logService: ILogService, ) { super(); } @@ -354,6 +379,17 @@ export abstract class AbstractWorkspaceEditingService extends Disposable impleme abstract enterWorkspace(workspaceUri: URI): Promise; + protected async fireDidEnterWorkspace(oldWorkspace: IAnyWorkspaceIdentifier, newWorkspace: IAnyWorkspaceIdentifier): Promise { + const event = new DidEnterWorkspaceEvent(oldWorkspace, newWorkspace); + this._onDidEnterWorkspace.fire(event); + + try { + await event.wait(); + } catch (error) { + this.logService.error('Error while waiting for participants of onDidEnterWorkspace to join:', error); + } + } + protected async doEnterWorkspace(workspaceUri: URI): Promise { if (this.environmentService.extensionTestsLocationURI) { throw new Error('Entering a new workspace is not possible in tests.'); diff --git a/src/vs/workbench/services/workspaces/browser/workspaceEditingService.ts b/src/vs/workbench/services/workspaces/browser/workspaceEditingService.ts index 47a334aedca52..9b1b35ab2bb34 100644 --- a/src/vs/workbench/services/workspaces/browser/workspaceEditingService.ts +++ b/src/vs/workbench/services/workspaces/browser/workspaceEditingService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, toWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; import { IJSONEditingService } from '../../configuration/common/jsonEditing.js'; import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; import { WorkspaceService } from '../../configuration/browser/configurationService.js'; @@ -23,6 +23,7 @@ import { IWorkspaceTrustManagementService } from '../../../../platform/workspace import { IWorkbenchConfigurationService } from '../../configuration/common/configuration.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export class BrowserWorkspaceEditingService extends AbstractWorkspaceEditingService { @@ -43,14 +44,20 @@ export class BrowserWorkspaceEditingService extends AbstractWorkspaceEditingServ @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, @IUserDataProfilesService userDataProfilesService: IUserDataProfilesService, @IUserDataProfileService userDataProfileService: IUserDataProfileService, + @ILogService logService: ILogService, ) { - super(jsonEditingService, contextService, configurationService, notificationService, commandService, fileService, textFileService, workspacesService, environmentService, fileDialogService, dialogService, hostService, uriIdentityService, workspaceTrustManagementService, userDataProfilesService, userDataProfileService); + super(jsonEditingService, contextService, configurationService, notificationService, commandService, fileService, textFileService, workspacesService, environmentService, fileDialogService, dialogService, hostService, uriIdentityService, workspaceTrustManagementService, userDataProfilesService, userDataProfileService, logService); } async enterWorkspace(workspaceUri: URI): Promise { + const oldWorkspace = toWorkspaceIdentifier(this.contextService.getWorkspace()); const result = await this.doEnterWorkspace(workspaceUri); if (result) { + // Fire event to allow participants to join + // and possibly migrate data into this workspace + await this.fireDidEnterWorkspace(oldWorkspace, result.workspace); + // Open workspace in same window await this.hostService.openWindow([{ workspaceUri }], { forceReuseWindow: true }); } diff --git a/src/vs/workbench/services/workspaces/common/workspaceEditing.ts b/src/vs/workbench/services/workspaces/common/workspaceEditing.ts index 36dd601e6e256..9169fc79b9132 100644 --- a/src/vs/workbench/services/workspaces/common/workspaceEditing.ts +++ b/src/vs/workbench/services/workspaces/common/workspaceEditing.ts @@ -3,17 +3,37 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../base/common/event.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { URI } from '../../../../base/common/uri.js'; -import { IWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; +import { IAnyWorkspaceIdentifier, IWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; export const IWorkspaceEditingService = createDecorator('workspaceEditingService'); +/** + * An event that is fired after entering a workspace. Clients can join the entering + * by providing a promise from the join method. This allows for long running operations + * to complete (e.g. to migrate data into the new workspace) before the workspace + * is fully entered. + */ +export interface IDidEnterWorkspaceEvent { + readonly oldWorkspace: IAnyWorkspaceIdentifier; + readonly newWorkspace: IAnyWorkspaceIdentifier; + + join(promise: Promise): void; +} + export interface IWorkspaceEditingService { readonly _serviceBrand: undefined; + /** + * Fired after the workspace is entered. Allows listeners to join the + * entering with a promise to migrate data into this new workspace. + */ + readonly onDidEnterWorkspace: Event; + /** * Add folders to the existing workspace. * When `donotNotifyError` is `true`, error will be bubbled up otherwise, the service handles the error with proper message and action diff --git a/src/vs/workbench/services/workspaces/electron-browser/workspaceEditingService.ts b/src/vs/workbench/services/workspaces/electron-browser/workspaceEditingService.ts index cbdca7d06f9fa..1ed42eca3ec1d 100644 --- a/src/vs/workbench/services/workspaces/electron-browser/workspaceEditingService.ts +++ b/src/vs/workbench/services/workspaces/electron-browser/workspaceEditingService.ts @@ -6,7 +6,7 @@ import { localize } from '../../../../nls.js'; import { IWorkspaceEditingService } from '../common/workspaceEditing.js'; import { URI } from '../../../../base/common/uri.js'; -import { hasWorkspaceFileExtension, isUntitledWorkspace, isWorkspaceIdentifier, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { hasWorkspaceFileExtension, isUntitledWorkspace, isWorkspaceIdentifier, IWorkspaceContextService, toWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; import { IJSONEditingService } from '../../configuration/common/jsonEditing.js'; import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; import { WorkspaceService } from '../../configuration/browser/configurationService.js'; @@ -34,6 +34,7 @@ import { IWorkbenchConfigurationService } from '../../configuration/common/confi import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js'; import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export class NativeWorkspaceEditingService extends AbstractWorkspaceEditingService { @@ -60,8 +61,9 @@ export class NativeWorkspaceEditingService extends AbstractWorkspaceEditingServi @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, @IUserDataProfilesService userDataProfilesService: IUserDataProfilesService, @IUserDataProfileService userDataProfileService: IUserDataProfileService, + @ILogService logService: ILogService, ) { - super(jsonEditingService, contextService, configurationService, notificationService, commandService, fileService, textFileService, workspacesService, environmentService, fileDialogService, dialogService, hostService, uriIdentityService, workspaceTrustManagementService, userDataProfilesService, userDataProfileService); + super(jsonEditingService, contextService, configurationService, notificationService, commandService, fileService, textFileService, workspacesService, environmentService, fileDialogService, dialogService, hostService, uriIdentityService, workspaceTrustManagementService, userDataProfilesService, userDataProfileService, logService); this.registerListeners(); } @@ -179,6 +181,7 @@ export class NativeWorkspaceEditingService extends AbstractWorkspaceEditingServi return; } + const oldWorkspace = toWorkspaceIdentifier(this.contextService.getWorkspace()); const result = await this.doEnterWorkspace(workspaceUri); if (result) { @@ -190,6 +193,9 @@ export class NativeWorkspaceEditingService extends AbstractWorkspaceEditingServi const newBackupWorkspaceHome = result.backupPath ? URI.file(result.backupPath).with({ scheme: this.environmentService.userRoamingDataHome.scheme }) : undefined; this.workingCopyBackupService.reinitialize(newBackupWorkspaceHome); } + + // Fire event to allow participants to join + await this.fireDidEnterWorkspace(oldWorkspace, result.workspace); } // TODO@aeschli: workaround until restarting works diff --git a/src/vs/workbench/services/workspaces/test/browser/workspaceEditingService.test.ts b/src/vs/workbench/services/workspaces/test/browser/workspaceEditingService.test.ts new file mode 100644 index 0000000000000..249f795a571f9 --- /dev/null +++ b/src/vs/workbench/services/workspaces/test/browser/workspaceEditingService.test.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { DidEnterWorkspaceEvent } from '../../browser/abstractWorkspaceEditingService.js'; +import { UNKNOWN_EMPTY_WINDOW_WORKSPACE } from '../../../../../platform/workspace/common/workspace.js'; + +suite('WorkspaceEditingService', () => { + + suite('DidEnterWorkspaceEvent', () => { + + test('event captures old workspace and new workspace URI', () => { + const oldWorkspace = { id: 'old-folder', uri: URI.file('/old/folder') }; + const newWorkspace = { id: 'new-workspace', configPath: URI.file('/test/workspace.code-workspace') }; + const event = new DidEnterWorkspaceEvent(oldWorkspace, newWorkspace); + + assert.strictEqual(event.oldWorkspace, oldWorkspace); + assert.strictEqual(event.newWorkspace, newWorkspace); + }); + + test('join collects promises', async () => { + const newWorkspace = { id: 'new-workspace', configPath: URI.file('/test/workspace.code-workspace') }; + const event = new DidEnterWorkspaceEvent(UNKNOWN_EMPTY_WINDOW_WORKSPACE, newWorkspace); + + let executed1 = false; + let executed2 = false; + + event.join( + (async () => { executed1 = true; })(), + ); + + event.join( + (async () => { executed2 = true; })(), + ); + + await event.wait(); + + assert.strictEqual(executed1, true, 'First promise should have executed'); + assert.strictEqual(executed2, true, 'Second promise should have executed'); + }); + + test('wait resolves when all promises complete', async () => { + const newWorkspace = { id: 'new-workspace', configPath: URI.file('/test/workspace.code-workspace') }; + const event = new DidEnterWorkspaceEvent(UNKNOWN_EMPTY_WINDOW_WORKSPACE, newWorkspace); + + let resolve1: () => void; + let resolve2: () => void; + const promise1 = new Promise(r => { resolve1 = r; }); + const promise2 = new Promise(r => { resolve2 = r; }); + + event.join(promise1); + event.join(promise2); + + let waitCompleted = false; + const waitPromise = event.wait().then(() => { waitCompleted = true; }); + + // Should not be completed yet + await Promise.resolve(); + assert.strictEqual(waitCompleted, false); + + // Resolve first promise + resolve1!(); + await Promise.resolve(); + assert.strictEqual(waitCompleted, false); + + // Resolve second promise + resolve2!(); + await waitPromise; + assert.strictEqual(waitCompleted, true); + }); + + test('wait resolves immediately when no promises are joined', async () => { + const newWorkspace = { id: 'new-workspace', configPath: URI.file('/test/workspace.code-workspace') }; + const event = new DidEnterWorkspaceEvent(UNKNOWN_EMPTY_WINDOW_WORKSPACE, newWorkspace); + + await event.wait(); + // Should complete without error + }); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +});