diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index dc52a099eb25f..f1a57a2758432 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -634,19 +634,14 @@ class SendToNewChatAction extends Action2 { const inputBeforeClear = widget.getInput(); - // Cancel any in-progress request before clearing - if (widget.viewModel) { - chatService.cancelCurrentRequestForSession(widget.viewModel.sessionResource); - } - if (widget.viewModel?.model) { if (!(await handleCurrentEditingSession(widget.viewModel.model, undefined, dialogService))) { return; } } - await widget.clear(); - widget.acceptInput(inputBeforeClear, { storeToHistory: true }); + // Delegate cancellation and clearing to the widget accept flow so callers can use a single API + widget.acceptInput(inputBeforeClear, { storeToHistory: true, sendToNewChat: true }); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 2edca9a2966bd..37faeb0e0fe5d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -212,6 +212,11 @@ export interface IChatAcceptInputOptions { // box's current content is being accepted, or 'false' if a specific input // is being submitted to the widget. storeToHistory?: boolean; + /** + * If true, submit the input to the associated session and clear the widget (show empty/new chat) immediately. + * This allows sending a request 'in the background' while returning the UI to an empty chat view. + */ + sendToNewChat?: boolean; } export interface IChatWidgetViewModelChangeEvent { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 25da29c424b89..03308a17ef49f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2321,29 +2321,49 @@ export class ChatWidget extends Disposable implements IChatWidget { }; this.telemetryService.publicLog2('chatEditing/workingSetSize', { originalSize: uniqueWorkingSetEntries.size, actualSize: uniqueWorkingSetEntries.size }); } - this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); - if (this.currentRequest) { - // We have to wait the current request to be properly cancelled so that it has a chance to update the model with its result metadata. - // This is awkward, it's basically a limitation of the chat provider-based agent. - await Promise.race([this.currentRequest, timeout(1000)]); + // Support submitting the input and returning to an empty chat view (sendToNewChat). + const sessionResource = this.viewModel?.sessionResource; + + if (options?.sendToNewChat && sessionResource) { + // Cancel any in-progress request for that session before clearing + this.chatService.cancelCurrentRequestForSession(sessionResource); + if (this.currentRequest) { + // We have to wait a brief moment for the in-flight request to be settled so it can update model metadata. + await Promise.race([this.currentRequest, timeout(1000)]); + } + await this.clear(); + } else { + // Cancel any existing in-flight request on the current session + if (this.viewModel) { + this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); + if (this.currentRequest) { + // We have to wait the current request to be properly cancelled so that it has a chance to update the model with its result metadata. + await Promise.race([this.currentRequest, timeout(1000)]); + } + } } this.input.validateAgentMode(); - if (this.viewModel.model.checkpoint) { + if (this.viewModel?.model?.checkpoint) { const requests = this.viewModel.model.getRequests(); for (let i = requests.length - 1; i >= 0; i -= 1) { const request = requests[i]; if (request.shouldBeBlocked) { - this.chatService.removeRequest(this.viewModel.sessionResource, request.id); + this.chatService.removeRequest(sessionResource ?? this.viewModel!.sessionResource, request.id); } } } - if (this.viewModel.sessionResource) { - this.chatAccessibilityService.acceptRequest(this._viewModel!.sessionResource); + + const targetSessionResource = sessionResource ?? this.viewModel?.sessionResource; + if (!targetSessionResource) { + this.chatAccessibilityService.disposeRequest(this.viewModel?.sessionResource); + return; } - const result = await this.chatService.sendRequest(this.viewModel.sessionResource, requestInputs.input, { + this.chatAccessibilityService.acceptRequest(targetSessionResource); + + const result = await this.chatService.sendRequest(targetSessionResource, requestInputs.input, { userSelectedModelId: this.input.currentLanguageModel, location: this.location, locationData: this._location.resolveData?.(), diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/sendToNewChat.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/sendToNewChat.test.ts new file mode 100644 index 0000000000000..002841a886a15 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/actions/sendToNewChat.test.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { SendToNewChatAction } from '../../../../browser/actions/chatExecuteActions.js'; +import { IChatWidget } from '../../../../browser/chat.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; + +suite('SendToNewChatAction', () => { + let store: DisposableStore; + + setup(() => { + store = new DisposableStore(); + }); + + teardown(() => { + store.dispose(); + }); + + test('delegates to widget.acceptInput with sendToNewChat option', async () => { + const insta = workbenchInstantiationService(undefined, store); + const action = new SendToNewChatAction(); + + let capturedInput: string | undefined; + let capturedOptions: any | undefined; + + const fakeWidget: Partial = { + getInput: () => 'hello', + acceptInput: async (input?: string, options?: any) => { + capturedInput = input; + capturedOptions = options; + return undefined as any; + } + }; + + // call action with the widget provided in the context + await insta.invokeFunction(accessor => action.run(accessor, { widget: fakeWidget as IChatWidget })); + + assert.strictEqual(capturedInput, 'hello'); + assert.ok(capturedOptions?.sendToNewChat, 'sendToNewChat option should be set'); + assert.strictEqual(capturedOptions?.storeToHistory, true, 'storeToHistory should be true'); + }); + + test('does nothing when widget is not available', async () => { + const insta = workbenchInstantiationService(undefined, store); + const action = new SendToNewChatAction(); + + // Should not throw + await insta.invokeFunction(accessor => action.run(accessor, undefined)); + }); + + test('ChatWidget _acceptInput with sendToNewChat clears and sends to original session', async () => { + // Build a minimal fake 'this' that mirrors the fields used by _acceptInput + const sent: Array<{ session: string; message: string }> = []; + let cancelledSession: string | undefined; + let cleared = false; + let accessibilityAccepted: string | undefined; + let accessibilityDisposed: string | undefined; + + const fakeThis: any = { + viewModel: { sessionResource: { toString: () => 'session:1' }, getItems: () => [] }, + + getInput: () => 'editor text', + _applyPromptFileIfSet: async () => { }, + _autoAttachInstructions: async () => { }, + chatService: { + sendRequest: async (session: any, message: string) => { + sent.push({ session: session.toString ? session.toString() : String(session), message }); + return { + responseCompletePromise: Promise.resolve(), + responseCreatedPromise: Promise.resolve({}), + agent: {} + }; + }, + cancelCurrentRequestForSession: (s: any) => { cancelledSession = s.toString ? s.toString() : String(s); } + }, + chatAccessibilityService: { + acceptRequest: (s: any) => { accessibilityAccepted = s.toString ? s.toString() : String(s); }, + disposeRequest: (s: any) => { accessibilityDisposed = s.toString ? s.toString() : String(s); }, + acceptResponse: () => { /* noop */ } + }, + currentRequest: undefined, + clear: async () => { cleared = true; }, + handleDelegationExitIfNeeded: async () => { /* noop */ }, + viewOptions: {}, + _location: { resolveData: () => undefined }, + input: { currentLanguageModel: undefined, currentModeKind: undefined, getAttachedContext: () => ({ asArray: () => [] }), getAttachedAndImplicitContext: () => ({ asArray: () => [] }), acceptInput: () => { /* noop */ } }, + getModeRequestOptions: () => ({}), + updateChatViewVisibility: () => { /* noop */ }, + }; + + // Call private method + await (ChatWidget as any).prototype._acceptInput.call(fakeThis, { query: 'my request' }, { sendToNewChat: true }); + + assert.strictEqual(cancelledSession, 'session:1'); + assert.strictEqual(cleared, true); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].session, 'session:1'); + assert.strictEqual(sent[0].message, 'my request'); + assert.strictEqual(accessibilityAccepted, 'session:1'); + }); +});