mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-29 00:46:00 +01:00
Merge pull request #292775 from microsoft/connor4312/queuing-polishes
chat: polishes for steering/queued messages
This commit is contained in:
@@ -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<boolean> {
|
||||
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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
if (!isRemovePendingRequestContext(context)) {
|
||||
|
||||
// Support both toolbar context (IChatRequestViewModel) and command context (IChatRemovePendingRequestContext)
|
||||
if (isRequestVM(context) && context.pendingKind) {
|
||||
chatService.removePendingRequest(context.sessionResource, context.id);
|
||||
return;
|
||||
}
|
||||
|
||||
chatService.removePendingRequest(context.sessionResource, context.pendingRequestId);
|
||||
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 (!isRequestVM(context) || !context.pendingKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -648,6 +648,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
|
||||
ChatContextKeys.isResponse.bindTo(templateData.contextKeyService).set(isResponseVM(element));
|
||||
ChatContextKeys.itemId.bindTo(templateData.contextKeyService).set(element.id);
|
||||
ChatContextKeys.isRequest.bindTo(templateData.contextKeyService).set(isRequestVM(element));
|
||||
ChatContextKeys.isPendingRequest.bindTo(templateData.contextKeyService).set(isRequestVM(element) && !!element.pendingKind);
|
||||
ChatContextKeys.responseDetectedAgentCommand.bindTo(templateData.contextKeyService).set(isResponseVM(element) && element.agentOrSlashCommandDetected);
|
||||
if (isResponseVM(element)) {
|
||||
ChatContextKeys.responseSupportsIssueReporting.bindTo(templateData.contextKeyService).set(!!element.agent?.metadata.supportIssueReporting);
|
||||
@@ -698,11 +699,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
|
||||
templateData.checkpointToolbar.context = element;
|
||||
const checkpointEnabled = this.configService.getValue<boolean>(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;
|
||||
|
||||
@@ -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<boolean>;
|
||||
private readonly _agentSupportsAttachmentsContextKey: IContextKey<boolean>;
|
||||
private readonly _sessionIsEmptyContextKey: IContextKey<boolean>;
|
||||
private readonly _hasPendingRequestsContextKey: IContextKey<boolean>;
|
||||
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<IChatResponseModel | undefined> {
|
||||
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<boolean> {
|
||||
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<IChatSendRequestOptions> {
|
||||
return {
|
||||
modeInfo: this.input.currentModeInfo,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ export namespace ChatContextKeys {
|
||||
|
||||
export const isResponse = new RawContextKey<boolean>('chatResponse', false, { type: 'boolean', description: localize('chatResponse', "The chat item is a response.") });
|
||||
export const isRequest = new RawContextKey<boolean>('chatRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") });
|
||||
export const isPendingRequest = new RawContextKey<boolean>('chatRequestIsPending', false, { type: 'boolean', description: localize('chatRequestIsPending', "True when the chat request item is pending in the queue.") });
|
||||
export const itemId = new RawContextKey<string>('chatItemId', '', { type: 'string', description: localize('chatItemId', "The id of the chat item.") });
|
||||
export const lastItemId = new RawContextKey<string[]>('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<boolean>('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") });
|
||||
export const chatSessionIsEmpty = new RawContextKey<boolean>('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") });
|
||||
export const hasPendingRequests = new RawContextKey<boolean>('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") });
|
||||
export const pendingRequestCount = new RawContextKey<number>('chatPendingRequestCount', 0, { type: 'number', description: localize('chatPendingRequestCount', "Number of pending requests in the queue.") });
|
||||
|
||||
export const remoteJobCreating = new RawContextKey<boolean>('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") });
|
||||
export const hasRemoteCodingAgent = new RawContextKey<boolean>('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available"));
|
||||
|
||||
@@ -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<IChatDetail[]>;
|
||||
|
||||
@@ -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';
|
||||
@@ -731,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<ChatSendResult>();
|
||||
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<ChatSendResult> {
|
||||
this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`);
|
||||
|
||||
@@ -745,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<ChatSendResult>();
|
||||
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];
|
||||
@@ -1094,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 => {
|
||||
@@ -1123,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 {
|
||||
@@ -1138,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.
|
||||
@@ -1150,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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
@@ -1479,6 +1507,8 @@ export interface ISerializableChatData3 extends Omit<ISerializableChatData2, 've
|
||||
/** Current draft input state (added later, fully backwards compatible) */
|
||||
inputState?: ISerializableChatModelInputState;
|
||||
repoData?: IExportableRepoData;
|
||||
/** Pending requests that were queued but not yet processed */
|
||||
pendingRequests?: ISerializablePendingRequestData[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1825,13 +1855,13 @@ export class ChatModel extends Disposable implements IChatModel {
|
||||
}
|
||||
|
||||
setPendingRequests(requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void {
|
||||
const existingMap = new Map(this._pendingRequests.map(p => [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<ICodeBlockInfo>(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<ICodeBlockInfo>(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<IChatCodeCitati
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts IChatSendRequestOptions to a serializable format by extracting only
|
||||
* serializable fields and converting observables to static values.
|
||||
*/
|
||||
export function serializeSendOptions(options: IChatSendRequestOptions): ISerializableSendOptions {
|
||||
return {
|
||||
modeInfo: options.modeInfo,
|
||||
userSelectedModelId: options.userSelectedModelId,
|
||||
userSelectedTools: options.userSelectedTools?.get(),
|
||||
location: options.location,
|
||||
locationData: options.locationData,
|
||||
attempt: options.attempt,
|
||||
noCommandDetection: options.noCommandDetection,
|
||||
agentId: options.agentId,
|
||||
agentIdSilent: options.agentIdSilent,
|
||||
slashCommand: options.slashCommand,
|
||||
confirmation: options.confirmation,
|
||||
};
|
||||
}
|
||||
|
||||
export enum ChatRequestEditedFileEventKind {
|
||||
Keep = 1,
|
||||
Undo = 2,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { URI, UriComponents } from '../../../../../base/common/uri.js';
|
||||
import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js';
|
||||
import { ModifiedFileEntryState } from '../editing/chatEditingService.js';
|
||||
import { IParsedChatRequest } from '../requestParser/chatParserTypes.js';
|
||||
import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, SerializedChatResponsePart } from './chatModel.js';
|
||||
import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatPendingRequest, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, ISerializablePendingRequestData, SerializedChatResponsePart, serializeSendOptions } from './chatModel.js';
|
||||
import * as Adapt from './objectMutationLog.js';
|
||||
|
||||
/**
|
||||
@@ -159,6 +159,13 @@ const inputStateSchema = Adapt.object<ISerializableChatModelInputState, ISeriali
|
||||
contrib: Adapt.v(i => i.contrib, objectsEqual),
|
||||
});
|
||||
|
||||
const pendingRequestSchema = Adapt.object<IChatPendingRequest, ISerializablePendingRequestData>({
|
||||
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<IChatModel, ISerializableChatData>({
|
||||
version: Adapt.v(() => 3),
|
||||
creationDate: Adapt.v(m => m.timestamp),
|
||||
@@ -170,6 +177,7 @@ export const storageSchema = Adapt.object<IChatModel, ISerializableChatData>({
|
||||
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<IChatModel, ISerializableChatData> implements IChatDataSerializerLog {
|
||||
|
||||
+4
@@ -62,6 +62,10 @@ class MockChatService implements IChatService {
|
||||
|
||||
}
|
||||
|
||||
processPendingRequests(sessionResource: URI): void {
|
||||
|
||||
}
|
||||
|
||||
setLiveSessionItems(items: IChatDetail[]): void {
|
||||
this.liveSessionItems = items;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user