Skip to content

feat: support pushing workspace edits to ChatResponseStream #247356

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions src/vs/workbench/api/common/extHost.api.impl.ts
Original file line number Diff line number Diff line change
@@ -1573,6 +1573,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
ChatResultFeedbackKind: extHostTypes.ChatResultFeedbackKind,
ChatVariableLevel: extHostTypes.ChatVariableLevel,
ChatCompletionItem: extHostTypes.ChatCompletionItem,
ChatResponseWorkspaceEditPart: extHostTypes.ChatResponseWorkspaceEditPart,
ChatReferenceDiagnostic: extHostTypes.ChatReferenceDiagnostic,
CallHierarchyIncomingCall: extHostTypes.CallHierarchyIncomingCall,
CallHierarchyItem: extHostTypes.CallHierarchyItem,
10 changes: 8 additions & 2 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
@@ -55,7 +55,7 @@ import { IChatAgentMetadata, IChatAgentRequest, IChatAgentResult } from '../../c
import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common/chatCodeMapperService.js';
import { IChatRelatedFile, IChatRelatedFileProviderMetadata as IChatRelatedFilesProviderMetadata, IChatRequestDraft } from '../../contrib/chat/common/chatEditingService.js';
import { IChatProgressHistoryResponseContent } from '../../contrib/chat/common/chatModel.js';
import { IChatContentInlineReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatResponseErrorDetails, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService.js';
import { IChatContentInlineReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatResponseErrorDetails, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService.js';
import { IChatRequestVariableValue } from '../../contrib/chat/common/chatVariables.js';
import { ChatAgentLocation } from '../../contrib/chat/common/constants.js';
import { IChatMessage, IChatResponseFragment, ILanguageModelChatMetadata, ILanguageModelChatSelector, ILanguageModelsChangeEvent } from '../../contrib/chat/common/languageModels.js';
@@ -1432,9 +1432,15 @@ export type IDocumentContextDto = {
ranges: IRange[];
};

export interface IChatWorkspaceEditDto {
kind: 'workspaceEdit';
edit: IWorkspaceEditDto;
}

export type IChatProgressDto =
| Dto<Exclude<IChatProgress, IChatTask | IChatNotebookEdit>>
| Dto<Exclude<IChatProgress, IChatTask | IChatNotebookEdit | IChatWorkspaceEdit>>
| IChatTaskDto
| IChatWorkspaceEditDto
| IChatNotebookEditDto;

export interface ExtHostUrlsShape {
10 changes: 10 additions & 0 deletions src/vs/workbench/api/common/extHostChatAgents2.ts
Original file line number Diff line number Diff line change
@@ -226,6 +226,15 @@ class ChatAgentResponseStream {
_report(dto);
return this;
},
workspaceEdit(editOrDone) {
throwIfDone(this.workspaceEdit);
checkProposedApiEnabled(that._extension, 'chatParticipantAdditions');

const part = new extHostTypes.ChatResponseWorkspaceEditPart(editOrDone);
const dto = typeConvert.ChatResponseWorkspaceEditPart.from(part);
_report(dto);
return this;
},
notebookEdit(target, edits) {
throwIfDone(this.notebookEdit);
checkProposedApiEnabled(that._extension, 'chatParticipantAdditions');
@@ -249,6 +258,7 @@ class ChatAgentResponseStream {

if (
part instanceof extHostTypes.ChatResponseTextEditPart ||
part instanceof extHostTypes.ChatResponseWorkspaceEditPart ||
part instanceof extHostTypes.ChatResponseNotebookEditPart ||
part instanceof extHostTypes.ChatResponseMarkdownWithVulnerabilitiesPart ||
part instanceof extHostTypes.ChatResponseWarningPart ||
16 changes: 15 additions & 1 deletion src/vs/workbench/api/common/extHostTypeConverters.ts
Original file line number Diff line number Diff line change
@@ -2728,7 +2728,19 @@ export namespace ChatResponseTextEditPart {
result.isDone = part.done;
return result;
}
}

export namespace ChatResponseWorkspaceEditPart {
export function from(part: vscode.ChatResponseWorkspaceEditPart): extHostProtocol.IChatWorkspaceEditDto {
return {
kind: 'workspaceEdit',
edit: WorkspaceEdit.from(part.edit),
};
}

export function to(part: extHostProtocol.IChatWorkspaceEditDto): vscode.ChatResponseWorkspaceEditPart {
return new types.ChatResponseWorkspaceEditPart(WorkspaceEdit.to(part.edit));
}
}

export namespace NotebookEdit {
@@ -2827,7 +2839,7 @@ export namespace ChatResponseCodeCitationPart {

export namespace ChatResponsePart {

export function from(part: vscode.ChatResponsePart | vscode.ChatResponseTextEditPart | vscode.ChatResponseMarkdownWithVulnerabilitiesPart | vscode.ChatResponseWarningPart | vscode.ChatResponseConfirmationPart | vscode.ChatResponseReferencePart2 | vscode.ChatResponseMovePart | vscode.ChatResponseNotebookEditPart | vscode.ChatResponseExtensionsPart, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): extHostProtocol.IChatProgressDto {
export function from(part: vscode.ChatResponsePart | vscode.ChatResponseTextEditPart | vscode.ChatResponseMarkdownWithVulnerabilitiesPart | vscode.ChatResponseWarningPart | vscode.ChatResponseConfirmationPart | vscode.ChatResponseReferencePart2 | vscode.ChatResponseMovePart | vscode.ChatResponseNotebookEditPart | vscode.ChatResponseExtensionsPart | vscode.ChatResponseWorkspaceEditPart, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): extHostProtocol.IChatProgressDto {
if (part instanceof types.ChatResponseMarkdownPart) {
return ChatResponseMarkdownPart.from(part);
} else if (part instanceof types.ChatResponseAnchorPart) {
@@ -2858,6 +2870,8 @@ export namespace ChatResponsePart {
return ChatResponseMovePart.from(part);
} else if (part instanceof types.ChatResponseExtensionsPart) {
return ChatResponseExtensionsPart.from(part);
} else if (part instanceof types.ChatResponseWorkspaceEditPart) {
return ChatResponseWorkspaceEditPart.from(part);
}

return {
7 changes: 7 additions & 0 deletions src/vs/workbench/api/common/extHostTypes.ts
Original file line number Diff line number Diff line change
@@ -4708,6 +4708,13 @@ export class ChatResponseTextEditPart implements vscode.ChatResponseTextEditPart
}
}

export class ChatResponseWorkspaceEditPart implements vscode.ChatResponseWorkspaceEditPart {
edit: vscode.WorkspaceEdit;
constructor(edit: vscode.WorkspaceEdit) {
this.edit = edit;
}
}

export class ChatResponseNotebookEditPart implements vscode.ChatResponseNotebookEditPart {
uri: vscode.Uri;
edits: vscode.NotebookEdit[];
Original file line number Diff line number Diff line change
@@ -306,7 +306,7 @@ function codeblockHasClosingBackticks(str: string): boolean {
return !!str.match(/\n```+$/);
}

class CollapsedCodeBlock extends Disposable {
export class CollapsedCodeBlock extends Disposable {

public readonly element: HTMLElement;

Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as dom from '../../../../../base/browser/dom.js';
import { Emitter } from '../../../../../base/common/event.js';
import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../../base/common/map.js';
import { URI } from '../../../../../base/common/uri.js';
import { IWorkspaceFileEdit, IWorkspaceTextEdit } from '../../../../../editor/common/languages.js';
import { IChatProgressRenderableResponseContent, IChatWorkspaceEdit } from '../../common/chatModel.js';
import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js';
import { CollapsedCodeBlock } from './chatMarkdownContentPart.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';

export class ChatWorkspaceEditContentPart extends Disposable implements IChatContentPart {
public readonly domNode: HTMLElement;

private readonly _onDidChangeHeight = this._register(new Emitter<void>());
public readonly onDidChangeHeight = this._onDidChangeHeight.event;

constructor(
chatWorkspaceEdit: IChatWorkspaceEdit,
context: IChatContentPartRenderContext,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();

this.domNode = dom.$('.chat-workspace-edit');

const edits = chatWorkspaceEdit.edit.edits;
if (!edits.length) {
return;
}

// Group edits by resource for better organization
const editsByResource = new ResourceMap<{ textEdits: IWorkspaceTextEdit[]; fileEdits: IWorkspaceFileEdit[] }>();

for (const edit of edits) {
const resourceUri = 'resource' in edit ? edit.resource :
('newResource' in edit && edit.newResource) ? edit.newResource :
('oldResource' in edit && edit.oldResource) ? edit.oldResource : undefined;

if (!resourceUri) {
continue;
}

if (!editsByResource.has(resourceUri)) {
editsByResource.set(resourceUri, { textEdits: [], fileEdits: [] });
}

const resourceEdits = editsByResource.get(resourceUri)!;

if ('resource' in edit && 'textEdit' in edit) {
resourceEdits.textEdits.push(edit as IWorkspaceTextEdit);
} else {
resourceEdits.fileEdits.push(edit as IWorkspaceFileEdit);
}
} // Render all resource edits
for (const [resourceUri, edits] of editsByResource.entries()) {
// For text edits, show a collapsed code block that can be expanded
if (edits.textEdits.length > 0) {
this.renderTextEdits(resourceUri, edits.textEdits, context);
}

// For file edits, show a collapsed code block with file operation info
if (edits.fileEdits.length > 0) {
this.renderFileEdits(edits.fileEdits, context);
}
}
}

private renderTextEdits(resource: URI, textEdits: IWorkspaceTextEdit[], context: IChatContentPartRenderContext): void {
const codeBlock = this.instantiationService.createInstance(
CollapsedCodeBlock,
context.element.sessionId,
context.element.id,
findUndoStopId(context)
);

codeBlock.render(resource, false);
this.domNode.appendChild(codeBlock.element);
}

private renderFileEdits(fileEdits: IWorkspaceFileEdit[], context: IChatContentPartRenderContext): void {
for (const fileEdit of fileEdits) {
const resourceUri = fileEdit.newResource || fileEdit.oldResource;
if (!resourceUri) {
continue;
}

// Create a collapsed code block for the file edit
const codeBlock = this.instantiationService.createInstance(
CollapsedCodeBlock,
context.element.sessionId,
context.element.id,
findUndoStopId(context)
);

codeBlock.render(resourceUri, false);
this.domNode.appendChild(codeBlock.element);
}
}

hasSameContent(other: IChatProgressRenderableResponseContent): boolean {
// No other change allowed for this content type
return other.kind === 'workspaceEdit';
}

addDisposable(disposable: IDisposable): void {
this._register(disposable);
}
}

function findUndoStopId(context: IChatContentPartRenderContext): string | undefined {
// Look through the content items to find the most recent undoStop
for (let i = context.contentIndex; i >= 0; i--) {
const item = context.content[i];
if (item.kind === 'undoStop') {
return item.id;
}
}
return undefined;
}
Original file line number Diff line number Diff line change
@@ -36,7 +36,7 @@ import { CellUri } from '../../../notebook/common/notebookCommon.js';
import { INotebookService } from '../../../notebook/common/notebookService.js';
import { IChatAgentService } from '../../common/chatAgents.js';
import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, chatEditingSnapshotScheme, IChatEditingService, IChatEditingSession, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/chatEditingService.js';
import { ChatModel, IChatResponseModel, isCellTextEditOperation } from '../../common/chatModel.js';
import { ChatModel, IChatResponseModel, IChatWorkspaceEdit, isCellTextEditOperation } from '../../common/chatModel.js';
import { IChatService } from '../../common/chatService.js';
import { ChatAgentLocation } from '../../common/constants.js';
import { ChatEditorInput } from '../chatEditorInput.js';
@@ -273,6 +273,49 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
continue;
}

if (part.kind === 'workspaceEdit') {
// Handle workspace edit parts which can affect multiple files at once
const workspaceEdit = part as IChatWorkspaceEdit;

if (workspaceEdit.edit && workspaceEdit.edit.edits) {
// Process each edit in the workspace edit
for (const edit of workspaceEdit.edit.edits) {
// Handle text edits
if ('resource' in edit && 'textEdit' in edit) {
const resource = edit.resource;
ensureEditorOpen(resource);

// Start streaming edits for this resource
const streamingEdits = session.startStreamingEdits(
CellUri.parse(resource)?.notebook ?? resource,
responseModel,
undoStop
);

// Convert to TextEdit objects and push
streamingEdits.pushText([edit.textEdit]);
streamingEdits.complete();
}
// Handle file edits (delete, rename)
else if ('oldResource' in edit || 'newResource' in edit) {
// Ensure the new resource is opened if it's a rename/move operation
if (edit.newResource) {
ensureEditorOpen(edit.newResource);
}

// TODO@joyceerhl support rendering file delete/rename
session._bulkEditService.apply({
edits: [edit]
}, {
respectAutoSaveConfig: true,
showPreview: false
});
}
}
}
continue;
}

if (part.kind !== 'textEditGroup' && part.kind !== 'notebookEditGroup') {
continue;
}
13 changes: 12 additions & 1 deletion src/vs/workbench/contrib/chat/browser/chatListRenderer.ts
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@ import { IChatAgentMetadata } from '../common/chatAgents.js';
import { ChatContextKeys } from '../common/chatContextKeys.js';
import { IChatRequestVariableEntry, IChatTextEditGroup } from '../common/chatModel.js';
import { chatSubcommandLeader } from '../common/chatParserTypes.js';
import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatTask, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop } from '../common/chatService.js';
import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatTask, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatWorkspaceEdit } from '../common/chatService.js';
import { IChatCodeCitations, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatWorkingProgress, isRequestVM, isResponseVM } from '../common/chatViewModel.js';
import { getNWords } from '../common/chatWordCounter.js';
import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js';
@@ -72,6 +72,7 @@ import { ChatTextEditContentPart, DiffEditorPool } from './chatContentParts/chat
import { ChatToolInvocationPart } from './chatContentParts/chatToolInvocationPart.js';
import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js';
import { ChatWarningContentPart } from './chatContentParts/chatWarningContentPart.js';
import { ChatWorkspaceEditContentPart } from './chatContentParts/chatWorkspaceContentPart.js';
import { ChatMarkdownDecorationsRenderer } from './chatMarkdownDecorationsRenderer.js';
import { ChatMarkdownRenderer } from './chatMarkdownRenderer.js';
import { ChatEditorOptions } from './chatOptions.js';
@@ -892,6 +893,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
return this.instantiationService.createInstance(ChatCommandButtonContentPart, content, context);
} else if (content.kind === 'textEditGroup') {
return this.renderTextEdit(context, content, templateData);
} else if (content.kind === 'workspaceEdit') {
return this.renderWorkspaceEdit(context, content, templateData);
} else if (content.kind === 'confirmation') {
return this.renderConfirmation(context, content, templateData);
} else if (content.kind === 'warning') {
@@ -1058,6 +1061,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
return this.instantiationService.createInstance(ChatAttachmentsContentPart, variables, contentReferences, undefined);
}

private renderWorkspaceEdit(context: IChatContentPartRenderContext, workspaceEdit: IChatWorkspaceEdit, templateData: IChatListItemTemplate): IChatContentPart {
const part = this.instantiationService.createInstance(ChatWorkspaceEditContentPart, workspaceEdit, context);
part.addDisposable(part.onDidChangeHeight(() => {
this.updateItemHeight(templateData);
}));
return part;
}

private renderTextEdit(context: IChatContentPartRenderContext, chatTextEdit: IChatTextEditGroup, templateData: IChatListItemTemplate): IChatContentPart {
const textEditPart = this.instantiationService.createInstance(ChatTextEditContentPart, chatTextEdit, context, this.rendererOptions, this._diffEditorPool, this._currentLayoutWidth);
textEditPart.addDisposable(textEditPart.onDidChangeHeight(() => {
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.