feat: associate chat references and warnings with chat progress (#212391)

feat: associate chat references and warnings with chat progress
This commit is contained in:
Joyce Er 2024-05-10 18:02:08 -07:00 committed by GitHub
parent 351fa19d43
commit bbc0159f43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 162 additions and 52 deletions

View file

@ -5,6 +5,8 @@
import { DeferredPromise } from 'vs/base/common/async';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Emitter, Event } from 'vs/base/common/event';
import { IMarkdownString } from 'vs/base/common/htmlContent';
import { Disposable, DisposableMap, IDisposable } from 'vs/base/common/lifecycle';
import { revive } from 'vs/base/common/marshalling';
import { escapeRegExpCharacters } from 'vs/base/common/strings';
@ -25,7 +27,7 @@ import { AddDynamicVariableAction, IAddDynamicVariableContext } from 'vs/workben
import { ChatAgentLocation, IChatAgentImplementation, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ChatRequestAgentPart } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChatFollowup, IChatProgress, IChatService } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatContentReference, IChatFollowup, IChatProgress, IChatService, IChatTask, IChatWarningMessage } from 'vs/workbench/contrib/chat/common/chatService';
import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
@ -36,6 +38,36 @@ interface AgentData {
hasFollowups?: boolean;
}
class MainThreadChatTask implements IChatTask {
public readonly kind = 'progressTask';
public readonly deferred = new DeferredPromise<string | void>();
private readonly _onDidAddProgress = new Emitter<IChatWarningMessage | IChatContentReference>();
public get onDidAddProgress(): Event<IChatWarningMessage | IChatContentReference> { return this._onDidAddProgress.event; }
public readonly progress: (IChatWarningMessage | IChatContentReference)[] = [];
constructor(public content: IMarkdownString) { }
task() {
return this.deferred.p;
}
isSettled() {
return this.deferred.isSettled;
}
complete(v: string | void) {
this.deferred.complete(v);
}
add(progress: IChatWarningMessage | IChatContentReference): void {
this.progress.push(progress);
this._onDidAddProgress.fire(progress);
}
}
@extHostNamedCustomer(MainContext.MainThreadChatAgents2)
export class MainThreadChatAgents2 extends Disposable implements MainThreadChatAgentsShape2 {
@ -46,7 +78,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
private readonly _proxy: ExtHostChatAgentsShape2;
private _responsePartHandlePool = 0;
private readonly _activeResponsePartPromises = new Map<string, DeferredPromise<string | void>>();
private readonly _activeTasks = new Map<string, IChatTask>();
constructor(
extHostContext: IExtHostContext,
@ -172,26 +204,33 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
}
async $handleProgressChunk(requestId: string, progress: IChatProgressDto, responsePartHandle?: number): Promise<number | void> {
if (progress.kind === 'progressTask') {
const revivedProgress = revive(progress) as IChatProgress;
if (revivedProgress.kind === 'progressTask') {
const handle = ++this._responsePartHandlePool;
const responsePartId = `${requestId}_${handle}`;
const deferredContentPromise = new DeferredPromise<string | void>();
this._activeResponsePartPromises.set(responsePartId, deferredContentPromise);
this._pendingProgress.get(requestId)?.({ ...progress, task: () => deferredContentPromise.p, isSettled: () => deferredContentPromise.isSettled });
const task = new MainThreadChatTask(revivedProgress.content);
this._activeTasks.set(responsePartId, task);
this._pendingProgress.get(requestId)?.(task);
return handle;
} else if (progress.kind === 'progressTaskResult' && responsePartHandle !== undefined) {
} else if (responsePartHandle !== undefined) {
const responsePartId = `${requestId}_${responsePartHandle}`;
const deferredContentPromise = this._activeResponsePartPromises.get(responsePartId);
if (deferredContentPromise && progress.content) {
deferredContentPromise.complete(progress.content.value);
this._activeResponsePartPromises.delete(responsePartId);
} else {
deferredContentPromise?.complete(undefined);
const task = this._activeTasks.get(responsePartId);
switch (revivedProgress.kind) {
case 'progressTaskResult':
if (task && revivedProgress.content) {
task.complete(revivedProgress.content.value);
this._activeTasks.delete(responsePartId);
} else {
task?.complete(undefined);
}
return responsePartHandle;
case 'warning':
case 'reference':
task?.add(revivedProgress);
return;
}
return responsePartHandle;
}
const revivedProgress = revive(progress);
this._pendingProgress.get(requestId)?.(revivedProgress as IChatProgress);
this._pendingProgress.get(requestId)?.(revivedProgress);
}
$registerAgentCompletionsProvider(handle: number, triggerCharacters: string[]): void {

View file

@ -68,17 +68,31 @@ class ChatAgentResponseStream {
}
}
const _report = (progress: IChatProgressDto, task?: () => Thenable<string | void>) => {
const _report = (progress: IChatProgressDto, task?: (progress: vscode.Progress<vscode.ChatResponseWarningPart | vscode.ChatResponseReferencePart>) => Thenable<string | void>) => {
// Measure the time to the first progress update with real markdown content
if (typeof this._firstProgress === 'undefined' && 'content' in progress) {
this._firstProgress = this._stopWatch.elapsed();
}
Promise.all([this._proxy.$handleProgressChunk(this._request.requestId, progress), task ? task() : undefined]).then(([handle, res]) => {
if (typeof handle === 'number' && task) {
this._proxy.$handleProgressChunk(this._request.requestId, typeConvert.ChatTaskResult.from(res), handle);
}
});
this._proxy.$handleProgressChunk(this._request.requestId, progress)
.then((handle) => {
if (handle) {
task?.({
report: (p) => {
if (extHostTypes.MarkdownString.isMarkdownString(p.value)) {
this._proxy.$handleProgressChunk(this._request.requestId, typeConvert.ChatResponseWarningPart.from(<vscode.ChatResponseWarningPart>p), handle);
return;
} else {
this._proxy.$handleProgressChunk(this._request.requestId, typeConvert.ChatResponseReferencePart.from(<vscode.ChatResponseReferencePart>p), handle);
}
}
}).then((res) => {
if (typeof handle === 'number') {
this._proxy.$handleProgressChunk(this._request.requestId, typeConvert.ChatTaskResult.from(res), handle);
}
});
}
});
};
this._apiObject = {
@ -121,7 +135,7 @@ class ChatAgentResponseStream {
_report(dto);
return this;
},
progress(value, task?: (() => Thenable<string | void>)) {
progress(value, task?: ((progress: vscode.Progress<vscode.ChatResponseWarningPart>) => Thenable<string | void>)) {
throwIfDone(this.progress);
const part = new extHostTypes.ChatResponseProgressPart2(value, task);
const dto = task ? typeConvert.ChatTask.from(part) : typeConvert.ChatResponseProgressPart.from(part);

View file

@ -4376,8 +4376,8 @@ export class ChatResponseProgressPart {
export class ChatResponseProgressPart2 {
value: string;
task?: () => Thenable<string | void>;
constructor(value: string, task?: () => Thenable<string | void>) {
task?: (progress: vscode.Progress<vscode.ChatResponseWarningPart>) => Thenable<string | void>;
constructor(value: string, task?: (progress: vscode.Progress<vscode.ChatResponseWarningPart>) => Thenable<string | void>) {
this.value = value;
this.task = task;
}

View file

@ -456,7 +456,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
: data.kind === 'markdownContent'
? this.renderMarkdown(data.content, element, templateData, fillInIncompleteTokens)
: data.kind === 'progressMessage' && onlyProgressMessagesAfterI(value, index) ? this.renderProgressMessage(data, false) // TODO render command
: data.kind === 'progressTask' && onlyProgressMessagesAfterI(value, index) ? this.renderProgressMessage(data, data.isSettled ? !data.isSettled() : false)
: data.kind === 'progressTask' ? this.renderProgressTask(data, !data.deferred.isSettled, element, templateData)
: data.kind === 'command' ? this.renderCommandButton(element, data)
: data.kind === 'textEditGroup' ? this.renderTextEdit(element, data, templateData)
: data.kind === 'warning' ? this.renderNotification('warning', data.content)
@ -579,7 +579,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
} else if (part.kind === 'progressTask') {
partsToRender[index] = {
task: part,
isSettled: part.isSettled?.() ?? true
isSettled: part.isSettled?.() ?? true,
progressLength: part.progress.length,
};
} else {
const wordCountResult = this.getDataForProgressiveRender(element, contentToMarkdown(part.content), { renderedWordCount: 0, lastRenderTime: 0 });
@ -626,15 +627,15 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
} satisfies IChatProgressMessageRenderData;
}
else if (part.kind === 'progressTask' && isProgressTaskRenderData(renderedPart) && renderedPart.isSettled !== part.isSettled?.()) {
else if (part.kind === 'progressTask' && isProgressTaskRenderData(renderedPart)) {
const isSettled = part.isSettled?.() ?? true;
if (renderedPart.isSettled !== isSettled) {
partsToRender[index] = { task: part, isSettled };
if (renderedPart.isSettled !== isSettled || part.progress.length !== renderedPart.progressLength || isSettled) {
partsToRender[index] = { task: part, isSettled, progressLength: part.progress.length };
}
}
});
isFullyRendered = partsToRender.length === 0 && !somePartIsNotFullyRendered;
isFullyRendered = partsToRender.filter((p) => !('isSettled' in p) || !p.isSettled).length === 0 && !somePartIsNotFullyRendered;
if (isFullyRendered && element.isComplete) {
// Response is done and content is rendered, so do a normal render
@ -662,7 +663,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
result = null;
}
} else if (isProgressTaskRenderData(partToRender)) {
result = this.renderProgressMessage(partToRender.task, !partToRender.isSettled);
result = this.renderProgressTask(partToRender.task, !partToRender.isSettled, element, templateData);
} else if (isCommandButtonRenderData(partToRender)) {
result = this.renderCommandButton(element, partToRender);
} else if (isTextEditRenderData(partToRender)) {
@ -775,7 +776,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
private renderContentReferencesIfNeeded(element: ChatTreeItem, templateData: IChatListItemTemplate, disposables: DisposableStore): void {
if (isResponseVM(element) && this._usedReferencesEnabled && element.contentReferences.length) {
dom.show(templateData.referencesListContainer);
const contentReferencesListResult = this.renderContentReferencesListData(element.contentReferences, element, templateData);
const contentReferencesListResult = this.renderContentReferencesListData(null, element.contentReferences, element, templateData);
if (templateData.referencesListContainer.firstChild) {
templateData.referencesListContainer.replaceChild(contentReferencesListResult.element, templateData.referencesListContainer.firstChild!);
} else {
@ -787,11 +788,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}
}
private renderContentReferencesListData(data: ReadonlyArray<IChatContentReference>, element: IChatResponseViewModel, templateData: IChatListItemTemplate): { element: HTMLElement; dispose: () => void } {
private renderContentReferencesListData(task: IChatTask | null, data: ReadonlyArray<IChatContentReference | IChatWarningMessage>, element: IChatResponseViewModel, templateData: IChatListItemTemplate): { element: HTMLElement; dispose: () => void } {
const listDisposables = new DisposableStore();
const referencesLabel = data.length > 1 ?
const referencesLabel = task?.content.value ?? (data.length > 1 ?
localize('usedReferencesPlural', "Used {0} references", data.length) :
localize('usedReferencesSingular', "Used {0} reference", 1);
localize('usedReferencesSingular', "Used {0} reference", 1));
const iconElement = $('.chat-used-context-icon');
const icon = (element: IChatResponseViewModel) => element.usedReferencesExpanded ? Codicon.chevronDown : Codicon.chevronRight;
iconElement.classList.add(...ThemeIcon.asClassNameArray(icon(element)));
@ -826,7 +827,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
container.appendChild(list.getHTMLElement().parentElement!);
listDisposables.add(list.onDidOpen((e) => {
if (e.element) {
if (e.element && 'reference' in e.element) {
const uriOrLocation = 'variableName' in e.element.reference ? e.element.reference.value : e.element.reference;
const uri = URI.isUri(uriOrLocation) ? uriOrLocation :
uriOrLocation?.uri;
@ -869,6 +870,21 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
element.ariaLabel = expanded ? localize('usedReferencesExpanded', "{0}, expanded", label) : localize('usedReferencesCollapsed', "{0}, collapsed", label);
}
private renderProgressTask(task: IChatTask, showSpinner: boolean, element: ChatTreeItem, templateData: IChatListItemTemplate): IMarkdownRenderResult | undefined {
if (!isResponseVM(element)) {
return;
}
if (task.progress.length) {
const refs = this.renderContentReferencesListData(task, task.progress, element, templateData);
const node = dom.$('.chat-progress-task');
node.appendChild(refs.element);
return { element: node, dispose: refs.dispose };
}
return this.renderProgressMessage(task, showSpinner);
}
private renderProgressMessage(progress: IChatProgressMessage | IChatTask, showSpinner: boolean): IMarkdownRenderResult {
if (showSpinner) {
// this step is in progress, communicate it to SR users
@ -1338,9 +1354,9 @@ class TreePool extends Disposable {
}
class ContentReferencesListPool extends Disposable {
private _pool: ResourcePool<WorkbenchList<IChatContentReference>>;
private _pool: ResourcePool<WorkbenchList<IChatContentReference | IChatWarningMessage>>;
public get inUse(): ReadonlySet<WorkbenchList<IChatContentReference>> {
public get inUse(): ReadonlySet<WorkbenchList<IChatContentReference | IChatWarningMessage>> {
return this._pool.inUse;
}
@ -1353,14 +1369,14 @@ class ContentReferencesListPool extends Disposable {
this._pool = this._register(new ResourcePool(() => this.listFactory()));
}
private listFactory(): WorkbenchList<IChatContentReference> {
private listFactory(): WorkbenchList<IChatContentReference | IChatWarningMessage> {
const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility }));
const container = $('.chat-used-context-list');
this._register(createFileIconThemableTreeContainerScope(container, this.themeService));
const list = this.instantiationService.createInstance(
WorkbenchList<IChatContentReference>,
WorkbenchList<IChatContentReference | IChatWarningMessage>,
'ChatListRenderer',
container,
new ContentReferencesListDelegate(),
@ -1368,7 +1384,10 @@ class ContentReferencesListPool extends Disposable {
{
alwaysConsumeMouseWheel: false,
accessibilityProvider: {
getAriaLabel: (element: IChatContentReference) => {
getAriaLabel: (element: IChatContentReference | IChatWarningMessage) => {
if (element.kind === 'warning') {
return element.content.value;
}
const reference = element.reference;
if ('variableName' in reference) {
return reference.variableName;
@ -1382,7 +1401,11 @@ class ContentReferencesListPool extends Disposable {
getWidgetAriaLabel: () => localize('usedReferences', "Used References")
},
dnd: {
getDragURI: ({ reference }: IChatContentReference) => {
getDragURI: (element: IChatContentReference | IChatWarningMessage) => {
if (element.kind === 'warning') {
return null;
}
const { reference } = element;
if ('variableName' in reference) {
return null;
} else if (URI.isUri(reference)) {
@ -1400,7 +1423,7 @@ class ContentReferencesListPool extends Disposable {
return list;
}
get(): IDisposableReference<WorkbenchList<IChatContentReference>> {
get(): IDisposableReference<WorkbenchList<IChatContentReference | IChatWarningMessage>> {
const object = this._pool.get();
let stale = false;
return {
@ -1414,7 +1437,7 @@ class ContentReferencesListPool extends Disposable {
}
}
class ContentReferencesListDelegate implements IListVirtualDelegate<IChatContentReference> {
class ContentReferencesListDelegate implements IListVirtualDelegate<IChatContentReference | IChatWarningMessage> {
getHeight(element: IChatContentReference): number {
return 22;
}
@ -1429,7 +1452,7 @@ interface IChatContentReferenceListTemplate {
templateDisposables: IDisposable;
}
class ContentReferencesListRenderer implements IListRenderer<IChatContentReference, IChatContentReferenceListTemplate> {
class ContentReferencesListRenderer implements IListRenderer<IChatContentReference | IChatWarningMessage, IChatContentReferenceListTemplate> {
static TEMPLATE_ID = 'contentReferencesListRenderer';
readonly templateId: string = ContentReferencesListRenderer.TEMPLATE_ID;
@ -1450,12 +1473,18 @@ class ContentReferencesListRenderer implements IListRenderer<IChatContentReferen
if (ThemeIcon.isThemeIcon(data.iconPath)) {
return data.iconPath;
} else {
return this.themeService.getColorTheme().type === ColorScheme.DARK && data.iconPath?.dark ? data.iconPath?.dark :
data.iconPath?.light;
return this.themeService.getColorTheme().type === ColorScheme.DARK && data.iconPath?.dark
? data.iconPath?.dark
: data.iconPath?.light;
}
}
renderElement(data: IChatContentReference, index: number, templateData: IChatContentReferenceListTemplate, height: number | undefined): void {
renderElement(data: IChatContentReference | IChatWarningMessage, index: number, templateData: IChatContentReferenceListTemplate, height: number | undefined): void {
if (data.kind === 'warning') {
templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning });
return;
}
const reference = data.reference;
const icon = this.getReferenceIcon(data);
templateData.label.element.style.display = 'flex';

View file

@ -165,6 +165,10 @@
width: 100%;
}
.interactive-item-container .chat-progress-task {
padding-bottom: 8px;
}
.interactive-item-container .value .rendered-markdown table {
width: 100%;
text-align: left;
@ -431,6 +435,14 @@
margin-top: 1px;
}
.chat-used-context-list .codicon-warning {
color: var(--vscode-notificationsWarningIcon-foreground); /* Have to override default styles which apply to all lists */
}
.chat-used-context-list .monaco-icon-label-container {
color: var(--vscode-interactive-session-foreground);
}
.chat-notification-widget .chat-warning-codicon .codicon-warning {
color: var(--vscode-notificationsWarningIcon-foreground) !important; /* Have to override default styles which apply to all lists */
}

View file

@ -214,13 +214,21 @@ export class Response implements IResponse {
const responsePosition = this._responseParts.push(progress) - 1;
this._updateRepr(quiet);
const disp = progress.onDidAddProgress(() => {
this._updateRepr(false);
});
progress.task?.().then((content) => {
// Stop listening for progress updates once the task settles
disp.dispose();
// Replace the resolving part's content with the resolved response
if (typeof content === 'string') {
this._responseParts[responsePosition] = { ...progress, content: new MarkdownString(content) };
}
this._updateRepr(false);
});
} else {
this._responseParts.push(progress);
this._updateRepr(quiet);

View file

@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { DeferredPromise } from 'vs/base/common/async';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Event } from 'vs/base/common/event';
import { IMarkdownString } from 'vs/base/common/htmlContent';
@ -107,6 +108,12 @@ export interface IChatProgressMessage {
}
export interface IChatTask extends IChatTaskDto {
deferred: DeferredPromise<string | void>;
progress: (IChatWarningMessage | IChatContentReference)[];
onDidAddProgress: Event<IChatWarningMessage | IChatContentReference>;
add(progress: IChatWarningMessage | IChatContentReference): void;
complete: (result: string | void) => void;
task: () => Promise<string | void>;
isSettled: () => boolean;
}

View file

@ -98,6 +98,7 @@ export interface IChatProgressMessageRenderData {
export interface IChatTaskRenderData {
task: IChatTask;
isSettled: boolean;
progressLength: number;
}
export type IChatRenderData = IChatResponseProgressFileTreeData | IChatResponseMarkdownRenderData | IChatProgressMessageRenderData | IChatCommandButton | IChatTextEditGroup | IChatConfirmation | IChatTaskRenderData | IChatWarningMessage;

View file

@ -118,8 +118,8 @@ declare module 'vscode' {
export class ChatResponseProgressPart2 extends ChatResponseProgressPart {
value: string;
task?: () => Thenable<string | void>;
constructor(value: string, task?: () => Thenable<string | void>);
task?: (progress: Progress<ChatResponseWarningPart | ChatResponseReferencePart>) => Thenable<string | void>;
constructor(value: string, task?: (progress: Progress<ChatResponseWarningPart | ChatResponseReferencePart>) => Thenable<string | void>);
}
export interface ChatResponseStream {
@ -132,7 +132,7 @@ declare module 'vscode' {
* @param task If provided, a task to run while the progress is displayed. When the Thenable resolves, the progress will be marked complete in the UI, and the progress message will be updated to the resolved string if one is specified.
* @returns This stream.
*/
progress(value: string, task?: () => Thenable<string | void>): ChatResponseStream;
progress(value: string, task?: (progress: Progress<ChatResponseWarningPart | ChatResponseReferencePart>) => Thenable<string | void>): ChatResponseStream;
textEdit(target: Uri, edits: TextEdit | TextEdit[]): ChatResponseStream;
markdownWithVulnerabilities(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]): ChatResponseStream;