Add live progress tracking to local chat session descriptions (#278474)

* Initial plan

* Add live progress tracking to chat session descriptions

Co-authored-by: osortega <48293249+osortega@users.noreply.github.com>

* Track progress observable changes and prevent duplicate listeners

Co-authored-by: osortega <48293249+osortega@users.noreply.github.com>

* Enable description display for local chat sessions in progress

Co-authored-by: osortega <48293249+osortega@users.noreply.github.com>

* Implementation change and clean up

* Refactor

* Review comments

* Update src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Cache tool state and fix memory leak in model listener registration

Co-authored-by: osortega <48293249+osortega@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: osortega <48293249+osortega@users.noreply.github.com>
Co-authored-by: Osvaldo Ortega <osortega@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2025-11-21 04:53:34 +00:00
committed by GitHub
parent 414d678bfa
commit 0b951cc941
6 changed files with 143 additions and 13 deletions

View File

@@ -37,6 +37,9 @@ import { LEGACY_AGENT_SESSIONS_VIEW_ID, ChatAgentLocation, ChatModeKind } from '
import { CHAT_CATEGORY } from './actions/chatActions.js';
import { IChatEditorOptions } from './chatEditor.js';
import { NEW_CHAT_SESSION_ACTION_ID } from './chatSessions/common.js';
import { IChatModel, IChatProgressResponseContent, IChatRequestModel } from '../common/chatModel.js';
import { IChatToolInvocation } from '../common/chatService.js';
import { autorunSelfDisposable } from '../../../../base/common/observable.js';
const extensionPoint = ExtensionsRegistry.registerExtensionPoint<IChatSessionsExtensionPoint[]>({
extensionPoint: 'chatSessions',
@@ -267,6 +270,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
private readonly _sessions = new ResourceMap<ContributedChatSessionData>();
private readonly _editableSessions = new ResourceMap<IEditableData>();
private readonly _registeredRequestIds = new Set<string>();
private readonly _registeredModels = new Set<IChatModel>();
constructor(
@ILogService private readonly _logService: ILogService,
@@ -779,6 +784,61 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
};
}
public registerModelProgressListener(model: IChatModel, callback: () => void): void {
// Prevent duplicate registrations for the same model
if (this._registeredModels.has(model)) {
return;
}
this._registeredModels.add(model);
// Helper function to register listeners for a request
const registerRequestListeners = (request: IChatRequestModel) => {
if (!request.response || this._registeredRequestIds.has(request.id)) {
return;
}
this._registeredRequestIds.add(request.id);
this._register(request.response.onDidChange(() => {
callback();
}));
// Track tool invocation state changes
const responseParts = request.response.response.value;
responseParts.forEach((part: IChatProgressResponseContent) => {
if (part.kind === 'toolInvocation') {
const toolInvocation = part as IChatToolInvocation;
// Use autorun to listen for state changes
this._register(autorunSelfDisposable(reader => {
const state = toolInvocation.state.read(reader);
// Also track progress changes when executing
if (state.type === IChatToolInvocation.StateKind.Executing) {
state.progress.read(reader);
}
callback();
}));
}
});
};
// Listen for response changes on all existing requests
const requests = model.getRequests();
requests.forEach(registerRequestListeners);
// Listen for new requests being added
this._register(model.onDidChange(() => {
const currentRequests = model.getRequests();
currentRequests.forEach(registerRequestListeners);
}));
// Clean up when model is disposed
this._register(model.onDidDispose(() => {
this._registeredModels.delete(model);
}));
}
/**
* Creates a new chat session by delegating to the appropriate provider
* @param chatSessionType The type of chat session provider to use

View File

@@ -52,6 +52,12 @@ export class ChatSessionTracker extends Disposable {
const editor = e.editor as ChatEditorInput;
const sessionType = editor.getSessionType();
const model = this.chatService.getSession(editor.sessionResource!);
if (model) {
this.chatSessionsService.registerModelProgressListener(model, () => {
this.chatSessionsService.notifySessionItemsChanged(sessionType);
});
}
this.chatSessionsService.notifySessionItemsChanged(sessionType);
// Emit targeted event for this session type

View File

@@ -7,11 +7,11 @@ import { Codicon } from '../../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { ResourceSet } from '../../../../../base/common/map.js';
import { IObservable } from '../../../../../base/common/observable.js';
import * as nls from '../../../../../nls.js';
import { IWorkbenchContribution } from '../../../../common/contributions.js';
import { ModifiedFileEntryState } from '../../common/chatEditingService.js';
import { IChatModel } from '../../common/chatModel.js';
import { IChatService } from '../../common/chatService.js';
import { IChatService, IChatToolInvocation } from '../../common/chatService.js';
import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js';
import { ChatAgentLocation } from '../../common/constants.js';
import { IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat.js';
@@ -76,7 +76,9 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio
const register = () => {
this.registerModelTitleListener(widget);
if (widget.viewModel) {
this.registerProgressListener(widget.viewModel.model.requestInProgress);
this.chatSessionsService.registerModelProgressListener(widget.viewModel.model, () => {
this._onDidChangeChatSessionItems.fire();
});
}
};
// Listen for view model changes on this widget
@@ -87,12 +89,6 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio
register();
}
private registerProgressListener(observable: IObservable<boolean>) {
const progressEvent = Event.fromObservableLight(observable);
this._register(progressEvent(() => {
this._onDidChangeChatSessionItems.fire();
}));
}
private registerModelTitleListener(widget: IChatWidget): void {
const model = widget.viewModel?.model;
@@ -147,11 +143,13 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio
}
}
const statistics = model ? this.getSessionStatistics(model) : undefined;
const description = model ? this.getSessionDescription(model) : undefined;
const editorSession: ChatSessionItemWithProvider = {
resource: sessionDetail.sessionResource,
label: sessionDetail.title,
iconPath: Codicon.chatSparkle,
status,
description,
provider: this,
timing: {
startTime: startTime ?? Date.now(), // TODO@osortega this is not so good
@@ -206,10 +204,70 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio
modifiedFiles.add(edit.modifiedURI);
});
}
if (modifiedFiles.size === 0) {
return;
}
return {
files: modifiedFiles.size,
insertions: linesAdded,
deletions: linesRemoved,
};
}
private extractFileNameFromLink(filePath: string): string {
return filePath.replace(/\[.*?\]\(file:\/\/\/(?<path>[^)]+)\)/g, (_: string, __: string, ___: number, ____, groups?: { path?: string }) => {
const fileName = groups?.path ? groups.path.split('/').pop() || groups.path : '';
return fileName;
});
}
private getSessionDescription(chatModel: IChatModel): string | undefined {
const requests = chatModel.getRequests();
if (requests.length === 0) {
return undefined;
}
// Get the last request to check its response status
const lastRequest = requests[requests.length - 1];
const response = lastRequest?.response;
if (!response) {
return undefined;
}
// If the response is complete, show Finished
if (response.isComplete) {
return nls.localize('chat.sessions.description.finished', "Finished");
}
// Get the response parts to find tool invocations and progress messages
const responseParts = response.response.value;
let description: string = '';
for (let i = responseParts.length - 1; i >= 0; i--) {
const part = responseParts[i];
if (!description && part.kind === 'toolInvocation') {
const toolInvocation = part as IChatToolInvocation;
const state = toolInvocation.state.get();
if (state.type !== IChatToolInvocation.StateKind.Completed) {
const pastTenseMessage = toolInvocation.pastTenseMessage;
const invocationMessage = toolInvocation.invocationMessage;
const message = pastTenseMessage || invocationMessage;
description = typeof message === 'string' ? message : message?.value ?? '';
if (description) {
description = this.extractFileNameFromLink(description);
}
if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) {
const message = toolInvocation.confirmationMessages?.title && (typeof toolInvocation.confirmationMessages.title === 'string'
? toolInvocation.confirmationMessages.title
: toolInvocation.confirmationMessages.title.value);
description = message ?? `${nls.localize('chat.sessions.description.waitingForConfirmation', "Waiting for confirmation:")} ${description}`;
}
}
}
}
return description || nls.localize('chat.sessions.description.working', "Working...");
}
}

View File

@@ -259,7 +259,7 @@ export class SessionsRenderer extends Disposable implements ITreeRenderer<IChatS
iconTheme = session.iconPath;
}
const renderDescriptionOnSecondRow = this.configurationService.getValue<boolean>(ChatConfiguration.ShowAgentSessionsViewDescription) && session.provider.chatSessionType !== localChatSessionType;
const renderDescriptionOnSecondRow = this.configurationService.getValue<boolean>(ChatConfiguration.ShowAgentSessionsViewDescription);
if (renderDescriptionOnSecondRow && session.description) {
templateData.container.classList.toggle('multiline', true);
@@ -623,9 +623,9 @@ export class SessionsDelegate implements IListVirtualDelegate<ChatSessionItemWit
constructor(private readonly configurationService: IConfigurationService) { }
getHeight(element: ChatSessionItemWithProvider): number {
getHeight(element: ChatSessionItemWithProvider | ArchivedSessionItems): number {
// Return consistent height for all items (single-line layout)
if (element.description && this.configurationService.getValue(ChatConfiguration.ShowAgentSessionsViewDescription) && element.provider.chatSessionType !== localChatSessionType) {
if (this.configurationService.getValue(ChatConfiguration.ShowAgentSessionsViewDescription) && !(element instanceof ArchivedSessionItems) && element.description) {
return SessionsDelegate.ITEM_HEIGHT_WITH_DESCRIPTION;
} else {
return SessionsDelegate.ITEM_HEIGHT;

View File

@@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta
import { IEditableData } from '../../../common/views.js';
import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './chatAgents.js';
import { IChatEditingSession } from './chatEditingService.js';
import { IChatRequestVariableData } from './chatModel.js';
import { IChatModel, IChatRequestVariableData } from './chatModel.js';
import { IChatProgress } from './chatService.js';
export const enum ChatSessionStatus {
@@ -211,6 +211,7 @@ export interface IChatSessionsService {
getEditableData(sessionResource: URI): IEditableData | undefined;
isEditable(sessionResource: URI): boolean;
// #endregion
registerModelProgressListener(model: IChatModel, callback: () => void): void;
}
export const IChatSessionsService = createDecorator<IChatSessionsService>('chatSessionsService');

View File

@@ -11,6 +11,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { IEditableData } from '../../../../common/views.js';
import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from '../../common/chatAgents.js';
import { IChatModel } from '../../common/chatModel.js';
import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionProviderOptionGroup, IChatSessionsExtensionPoint, IChatSessionsService, SessionOptionsChangedCallback } from '../../common/chatSessionsService.js';
export class MockChatSessionsService implements IChatSessionsService {
@@ -215,4 +216,8 @@ export class MockChatSessionsService implements IChatSessionsService {
getContentProviderSchemes(): string[] {
return Array.from(this.contentProviders.keys());
}
registerModelProgressListener(model: IChatModel, callback: () => void): void {
throw new Error('Method not implemented.');
}
}