Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this name backwards? It's not sending to a new chat, it's sending to the current chat then opening a new chat... I will have to think of a catchy name

}

export interface IChatWidgetViewModelChangeEvent {
Expand Down
40 changes: 30 additions & 10 deletions src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2321,29 +2321,49 @@ export class ChatWidget extends Disposable implements IChatWidget {
};
this.telemetryService.publicLog2<ChatEditingWorkingSetEvent, ChatEditingWorkingSetClassification>('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?.(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IChatWidget> = {
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');
});
});