diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index b9a4732243d8c..8459073932d8b 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -356,7 +356,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // Register the provider handle - this tracks that a provider exists const disposables = new DisposableStore(); const changeEmitter = disposables.add(new Emitter()); - const provider: IChatSessionItemProvider = { chatSessionType, onDidChangeChatSessionItems: changeEmitter.event, @@ -370,8 +369,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat provider, onDidChangeItems: changeEmitter, }); + + disposables.add(this._chatSessionsService.registerChatModelChangeListeners( + this._chatService, + chatSessionType, + () => changeEmitter.fire() + )); } + $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 6cb0838a3243b..990cb4649c7d6 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -18,7 +18,7 @@ import { TestInstantiationService } from '../../../../platform/instantiation/tes import { ILogService, NullLogService } from '../../../../platform/log/common/log.js'; import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions.contribution.js'; import { IChatAgentRequest } from '../../../contrib/chat/common/chatAgents.js'; -import { IChatProgress, IChatProgressMessage } from '../../../contrib/chat/common/chatService.js'; +import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService.js'; import { IChatSessionItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../contrib/chat/common/chatUri.js'; import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; @@ -31,6 +31,7 @@ import { mock, TestExtensionService } from '../../../test/common/workbenchTestSe import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js'; import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; +import { MockChatService } from '../../../contrib/chat/test/common/mockChatService.js'; suite('ObservableChatSession', function () { let disposables: DisposableStore; @@ -387,6 +388,7 @@ suite('MainThreadChatSessions', function () { }; } }); + instantiationService.stub(IChatService, new MockChatService()); chatSessionsService = disposables.add(instantiationService.createInstance(ChatSessionsService)); instantiationService.stub(IChatSessionsService, chatSessionsService); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index 81535114537a1..022f21ce99ac3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -7,9 +7,9 @@ import { coalesce } from '../../../../../base/common/arrays.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { Schemas } from '../../../../../base/common/network.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IChatModel } from '../../common/chatModel.js'; import { IChatDetail, IChatService } from '../../common/chatService.js'; @@ -28,8 +28,6 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess readonly _onDidChangeChatSessionItems = this._register(new Emitter()); readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; - private readonly modelListeners = this._register(new DisposableMap()); - constructor( @IChatService private readonly chatService: IChatService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @@ -43,11 +41,11 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess private registerListeners(): void { - // Listen for models being added or removed - this._register(autorun(reader => { - const models = this.chatService.chatModels.read(reader); - this.registerModelListeners(models); - })); + this._register(this.chatSessionsService.registerChatModelChangeListeners( + this.chatService, + Schemas.vscodeLocalChatSession, + () => this._onDidChangeChatSessionItems.fire() + )); // Listen for global session items changes for our session type this._register(this.chatSessionsService.onDidChangeSessionItems(sessionType => { @@ -57,43 +55,6 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess })); } - private registerModelListeners(models: Iterable): void { - const seenKeys = new Set(); - - for (const model of models) { - const key = model.sessionResource.toString(); - seenKeys.add(key); - - if (!this.modelListeners.has(key)) { - this.modelListeners.set(key, this.registerSingleModelListeners(model)); - } - } - - // Clean up listeners for models that no longer exist - for (const key of this.modelListeners.keys()) { - if (!seenKeys.has(key)) { - this.modelListeners.deleteAndDispose(key); - } - } - - this._onDidChange.fire(); - } - - private registerSingleModelListeners(model: IChatModel): IDisposable { - const store = new DisposableStore(); - - this.chatSessionsService.registerModelProgressListener(model, () => { - this._onDidChangeChatSessionItems.fire(); - }); - - store.add(model.onDidChange(e => { - if (!e || e.kind === 'setCustomTitle') { - this._onDidChange.fire(); - } - })); - - return store; - } private modelToStatus(model: IChatModel): ChatSessionStatus | undefined { if (model.requestInProgress.get()) { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions.contribution.ts index d0b0a2d6376e8..a4546bddaf72e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions.contribution.ts @@ -8,7 +8,7 @@ import { raceCancellationError } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import * as resources from '../../../../base/common/resources.js'; @@ -37,9 +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 { IChatModel } from '../common/chatModel.js'; import { IChatService, IChatToolInvocation } from '../common/chatService.js'; -import { autorunSelfDisposable } from '../../../../base/common/observable.js'; +import { autorun, autorunIterableDelta, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { IChatRequestVariableEntry } from '../common/chatVariableEntries.js'; import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; @@ -278,8 +278,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _sessions = new ResourceMap(); private readonly _editableSessions = new ResourceMap(); - private readonly _registeredRequestIds = new Set(); - private readonly _registeredModels = new Set(); constructor( @ILogService private readonly _logService: ILogService, @@ -893,60 +891,44 @@ 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); - } + public registerChatModelChangeListeners( + chatService: IChatService, + chatSessionType: string, + onChange: () => void + ): IDisposable { + const disposableStore = new DisposableStore(); + const chatModelsICareAbout = chatService.chatModels.map(models => + Array.from(models).filter((model: IChatModel) => model.sessionResource.scheme === chatSessionType) + ); - callback(); + const listeners = new ResourceMap(); + const autoRunDisposable = autorunIterableDelta( + reader => chatModelsICareAbout.read(reader), + ({ addedValues, removedValues }) => { + removedValues.forEach((removed) => { + const listener = listeners.get(removed.sessionResource); + if (listener) { + listeners.delete(removed.sessionResource); + listener.dispose(); + } + }); + addedValues.forEach((added) => { + const changedSignal = added.lastRequestObs.map(last => last?.response && observableSignalFromEvent('chatSessions.modelChangeListener', last.response.onDidChange)); + listeners.set(added.sessionResource, autorun(reader => { + changedSignal.read(reader)?.read(reader); + onChange(); })); - } - }); - }; - // 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); + }); + } + ); + disposableStore.add(toDisposable(() => { + for (const listener of listeners.values()) { listener.dispose(); } })); + disposableStore.add(autoRunDisposable); + return disposableStore; } + public getSessionDescription(chatModel: IChatModel): string | undefined { const requests = chatModel.getRequests(); if (requests.length === 0) { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionTracker.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionTracker.ts deleted file mode 100644 index 87cf9433e5e24..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionTracker.ts +++ /dev/null @@ -1,158 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../../../base/common/event.js'; -import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; -import { GroupModelChangeKind } from '../../../../common/editor.js'; -import { EditorInput } from '../../../../common/editor/editorInput.js'; -import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; -import { IChatModel } from '../../common/chatModel.js'; -import { IChatService } from '../../common/chatService.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; -import { ChatEditorInput } from '../chatEditorInput.js'; -import { ChatSessionItemWithProvider, isChatSession } from './common.js'; - -export class ChatSessionTracker extends Disposable { - private readonly _onDidChangeEditors = this._register(new Emitter<{ sessionType: string; kind: GroupModelChangeKind }>()); - private readonly groupDisposables = this._register(new DisposableMap()); - readonly onDidChangeEditors = this._onDidChangeEditors.event; - - constructor( - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, - @IChatService private readonly chatService: IChatService, - @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - ) { - super(); - this.setupEditorTracking(); - } - - private setupEditorTracking(): void { - // Listen to all editor groups - this.editorGroupsService.groups.forEach(group => { - this.registerGroupListeners(group); - }); - // Listen for new groups - this._register(this.editorGroupsService.onDidAddGroup(group => { - this.registerGroupListeners(group); - })); - // Listen for deleted groups - this._register(this.editorGroupsService.onDidRemoveGroup(group => { - this.groupDisposables.deleteAndDispose(group.id); - })); - } - - private registerGroupListeners(group: IEditorGroup): void { - this.groupDisposables.set(group.id, group.onDidModelChange(e => { - if (!isChatSession(this.chatSessionsService.getContentProviderSchemes(), e.editor)) { - return; - } - - const editor = e.editor; - const sessionType = editor.getSessionType(); - - const model = editor.sessionResource && 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 - this._onDidChangeEditors.fire({ sessionType, kind: e.kind }); - })); - } - - public getLocalEditorsForSessionType(sessionType: string): ChatEditorInput[] { - const localEditors: ChatEditorInput[] = []; - - this.editorGroupsService.groups.forEach(group => { - group.editors.forEach(editor => { - if (editor instanceof ChatEditorInput && editor.getSessionType() === sessionType) { - localEditors.push(editor); - } - }); - }); - - return localEditors; - } - - async getHybridSessionsForProvider(provider: IChatSessionItemProvider): Promise { - if (provider.chatSessionType === localChatSessionType) { - return []; // Local provider doesn't need hybrid sessions - } - - const localEditors = this.getLocalEditorsForSessionType(provider.chatSessionType); - const hybridSessions: ChatSessionItemWithProvider[] = []; - - localEditors.forEach((editor, index) => { - const group = this.findGroupForEditor(editor); - if (!group) { - return; - } - if (editor.options.ignoreInView) { - return; - } - - let status: ChatSessionStatus = ChatSessionStatus.Completed; - let timestamp: number | undefined; - - if (editor.sessionResource) { - const model = this.chatService.getSession(editor.sessionResource); - const modelStatus = model ? this.modelToStatus(model) : undefined; - if (model && modelStatus) { - status = modelStatus; - const requests = model.getRequests(); - if (requests.length > 0) { - timestamp = requests[requests.length - 1].timestamp; - } - } - } - - const hybridSession: ChatSessionItemWithProvider = { - resource: editor.resource, - label: editor.getName(), - status: status, - provider, - timing: { - startTime: timestamp ?? Date.now() - } - }; - - hybridSessions.push(hybridSession); - }); - - return hybridSessions; - } - - private findGroupForEditor(editor: EditorInput): IEditorGroup | undefined { - for (const group of this.editorGroupsService.groups) { - if (group.editors.includes(editor)) { - return group; - } - } - return undefined; - } - - private modelToStatus(model: IChatModel): ChatSessionStatus | undefined { - if (model.requestInProgress.get()) { - return ChatSessionStatus.InProgress; - } - const requests = model.getRequests(); - if (requests.length > 0) { - const lastRequest = requests[requests.length - 1]; - if (lastRequest?.response) { - if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails) { - return ChatSessionStatus.Failed; - } else if (lastRequest.response.isComplete) { - return ChatSessionStatus.Completed; - } else { - return ChatSessionStatus.InProgress; - } - } - } - return undefined; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/view/chatSessionsView.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/view/chatSessionsView.ts index 62376b0e86451..417df3931ed59 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/view/chatSessionsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/view/chatSessionsView.ts @@ -28,7 +28,6 @@ import { ChatContextKeyExprs } from '../../../common/chatContextKeys.js'; import { IChatSessionItemProvider, IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LEGACY_AGENT_SESSIONS_VIEW_ID } from '../../../common/constants.js'; import { ACTION_ID_OPEN_CHAT } from '../../actions/chatActions.js'; -import { ChatSessionTracker } from '../chatSessionTracker.js'; import { SessionsViewPane } from './sessionsViewPane.js'; export class ChatSessionsView extends Disposable implements IWorkbenchContribution { @@ -53,20 +52,15 @@ export class ChatSessionsView extends Disposable implements IWorkbenchContributi export class ChatSessionsViewContrib extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatSessions'; - - private readonly sessionTracker: ChatSessionTracker; private readonly registeredViewDescriptors: Map = new Map(); constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ILogService private readonly logService: ILogService, @IProductService private readonly productService: IProductService, ) { super(); - this.sessionTracker = this._register(this.instantiationService.createInstance(ChatSessionTracker)); - // Initial check void this.updateViewRegistration(); @@ -184,7 +178,7 @@ export class ChatSessionsViewContrib extends Disposable implements IWorkbenchCon value: displayName, original: displayName, }, - ctorDescriptor: new SyncDescriptor(SessionsViewPane, [provider, this.sessionTracker, viewId]), + ctorDescriptor: new SyncDescriptor(SessionsViewPane, [provider, viewId]), canToggleVisibility: true, canMoveView: true, order: baseOrder, // Use computed order based on priority and alphabetical sorting @@ -212,7 +206,7 @@ export class ChatSessionsViewContrib extends Disposable implements IWorkbenchCon value: nls.localize('chat.sessions.gettingStarted', "Getting Started"), original: 'Getting Started', }, - ctorDescriptor: new SyncDescriptor(SessionsViewPane, [null, this.sessionTracker, gettingStartedViewId]), + ctorDescriptor: new SyncDescriptor(SessionsViewPane, [null, gettingStartedViewId]), canToggleVisibility: true, canMoveView: true, order: 1000, diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsTreeRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsTreeRenderer.ts index 9cd5122793bf1..c5e3ff2da757b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsTreeRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsTreeRenderer.ts @@ -21,7 +21,6 @@ import { createSingleCallFunction } from '../../../../../../base/common/function import { isMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import Severity from '../../../../../../base/common/severity.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -41,13 +40,12 @@ import { IEditorGroupsService } from '../../../../../services/editor/common/edit import { IWorkbenchLayoutService, Position } from '../../../../../services/layout/browser/layoutService.js'; import { getLocalHistoryDateFormatter } from '../../../../localHistory/browser/localHistory.js'; import { IChatService } from '../../../common/chatService.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/chatUri.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IMarshalledChatSessionContext } from '../../actions/chatSessionActions.js'; import { allowedChatMarkdownHtmlTags } from '../../chatContentMarkdownRenderer.js'; import '../../media/chatSessions.css'; -import { ChatSessionTracker } from '../chatSessionTracker.js'; import { ChatSessionItemWithProvider, extractTimestamp, getSessionItemContextOverlay, processSessionsWithTimeGrouping } from '../common.js'; interface ISessionTemplateData { @@ -546,7 +544,6 @@ export class SessionsDataSource implements IAsyncDataSource { + const result: (ChatSessionItemWithProvider | ArchivedSessionItems)[] = items.map(item => { const itemWithProvider = { ...item, provider: this.provider, timing: { startTime: extractTimestamp(item) ?? 0 } }; if (itemWithProvider.history) { this.archivedItems.pushItem(itemWithProvider); @@ -578,27 +575,9 @@ export class SessionsDataSource implements IAsyncDataSource item !== undefined); - // Add hybrid local editor sessions for this provider - if (this.provider.chatSessionType !== localChatSessionType) { - const hybridSessions = await this.sessionTracker.getHybridSessionsForProvider(this.provider); - const existingSessions = new ResourceSet(); - // Iterate only over the ungrouped items, the only group we support for now is history - ungroupedItems.forEach(s => existingSessions.add(s.resource)); - hybridSessions.forEach(session => { - if (!existingSessions.has(session.resource)) { - ungroupedItems.push(session as ChatSessionItemWithProvider); - existingSessions.add(session.resource); - } - }); - ungroupedItems = processSessionsWithTimeGrouping(ungroupedItems); - } - - const result = []; - result.push(...ungroupedItems); if (this.archivedItems.getItems().length > 0) { result.push(this.archivedItems); } - return result; } catch (error) { return []; diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts index f019a754044e8..7824cc781ab0b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts @@ -43,9 +43,7 @@ import { ACTION_ID_OPEN_CHAT } from '../../actions/chatActions.js'; import { IMarshalledChatSessionContext } from '../../actions/chatSessionActions.js'; import { IChatWidgetService } from '../../chat.js'; import { IChatEditorOptions } from '../../chatEditor.js'; -import { ChatSessionTracker } from '../chatSessionTracker.js'; import { ChatSessionItemWithProvider, getSessionItemContextOverlay, NEW_CHAT_SESSION_ACTION_ID } from '../common.js'; -import { LocalAgentsSessionsProvider } from '../../agentSessions/localAgentSessionsProvider.js'; import { ArchivedSessionItems, GettingStartedDelegate, GettingStartedRenderer, IGettingStartedItem, SessionsDataSource, SessionsDelegate, SessionsRenderer } from './sessionsTreeRenderer.js'; // Identity provider for session items @@ -80,7 +78,6 @@ export class SessionsViewPane extends ViewPane { constructor( private readonly provider: IChatSessionItemProvider, - private readonly sessionTracker: ChatSessionTracker, private readonly viewId: string, options: IViewPaneOptions, @IKeybindingService keybindingService: IKeybindingService, @@ -104,15 +101,6 @@ export class SessionsViewPane extends ViewPane { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); this.minimumBodySize = 44; - // Listen for changes in the provider if it's a LocalChatSessionsProvider - if (provider instanceof LocalAgentsSessionsProvider) { - this._register(provider.onDidChange(() => { - if (this.tree && this.isBodyVisible()) { - this.refreshTreeWithProgress(); - } - })); - } - // Listen for configuration changes to refresh view when description display changes this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ChatConfiguration.ShowAgentSessionsViewDescription)) { @@ -293,7 +281,7 @@ export class SessionsViewPane extends ViewPane { this.messageElement = append(container, $('.chat-sessions-message')); this.messageElement.style.display = 'none'; // Create the tree components - const dataSource = new SessionsDataSource(this.provider, this.sessionTracker); + const dataSource = new SessionsDataSource(this.provider); const delegate = new SessionsDelegate(this.configurationService); const identityProvider = new SessionsIdentityProvider(); const accessibilityProvider = new SessionsAccessibilityProvider(); diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index cfded1abb9eb9..dc9060a5a5d0f 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -221,6 +221,7 @@ export interface IChatResponseModel { setVote(vote: ChatAgentVoteDirection): void; setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void; setEditApplied(edit: IChatTextEditGroup, editCount: number): boolean; + updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatTask, quiet?: boolean): void; /** * Adopts any partially-undo {@link response} as the {@link entireResponse}. * Only valid when {@link isComplete}. This is needed because otherwise an @@ -1173,6 +1174,7 @@ export interface IChatModel extends IDisposable { readonly lastRequest: IChatRequestModel | undefined; /** Whether this model will be kept alive while it is running or has edits */ readonly willKeepAlive: boolean; + readonly lastRequestObs: IObservable; getRequests(): IChatRequestModel[]; setCheckpoint(requestId: string | undefined): void; @@ -1566,6 +1568,7 @@ export class ChatModel extends Disposable implements IChatModel { public setContributedChatSession(session: IChatSessionContext | undefined) { this._contributedChatSession = session; } + readonly lastRequestObs: IObservable; // TODO to be clear, this is not the same as the id from the session object, which belongs to the provider. // It's easier to be able to identify this model before its async initialization is complete @@ -1705,10 +1708,10 @@ export class ChatModel extends Disposable implements IChatModel { this._initialLocation = initialData?.initialLocation ?? initialModelProps.initialLocation; this._canUseTools = initialModelProps.canUseTools; - const lastRequest = observableFromEvent(this, this.onDidChange, () => this._requests.at(-1)); + this.lastRequestObs = observableFromEvent(this, this.onDidChange, () => this._requests.at(-1)); this._register(autorun(reader => { - const request = lastRequest.read(reader); + const request = this.lastRequestObs.read(reader); if (!request?.response) { return; } @@ -1727,11 +1730,11 @@ export class ChatModel extends Disposable implements IChatModel { })); })); - this.requestInProgress = lastRequest.map((request, r) => { + this.requestInProgress = this.lastRequestObs.map((request, r) => { return request?.response?.isInProgress.read(r) ?? false; }); - this.requestNeedsInput = lastRequest.map((request, r) => { + this.requestNeedsInput = this.lastRequestObs.map((request, r) => { return !!request?.response?.isPendingConfirmation.read(r); }); diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index 5f00ec6cdc505..00a2d6f81d857 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -196,7 +196,7 @@ export class ChatService extends Disposable implements IChatService { this._register(storageService.onWillSaveState(() => this.saveState())); - this.chatModels = derived(this, reader => this._sessionModels.observable.read(reader).values()); + this.chatModels = derived(this, reader => [...this._sessionModels.observable.read(reader).values()]); this.requestInProgressObs = derived(reader => { const models = this._sessionModels.observable.read(reader).values(); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 131818c822f9e..a2c6c0f3f7e3b 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -15,7 +15,7 @@ import { IEditableData } from '../../../common/views.js'; import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './chatAgents.js'; import { IChatEditingSession } from './chatEditingService.js'; import { IChatModel, IChatRequestVariableData } from './chatModel.js'; -import { IChatProgress } from './chatService.js'; +import { IChatProgress, IChatService } from './chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -220,7 +220,7 @@ export interface IChatSessionsService { getEditableData(sessionResource: URI): IEditableData | undefined; isEditable(sessionResource: URI): boolean; // #endregion - registerModelProgressListener(model: IChatModel, callback: () => void): void; + registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable; getSessionDescription(chatModel: IChatModel): string | undefined; } diff --git a/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts index 2257c73e15ab7..ed994224f2781 100644 --- a/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts @@ -673,48 +673,53 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Events', () => { - test('should fire onDidChange when a model is added via chatModels observable', async () => { + test('should fire onDidChangeChatSessionItems when model progress changes', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); - let changeEventCount = 0; - disposables.add(provider.onDidChange(() => { - changeEventCount++; - })); - - const sessionResource = LocalChatSessionUri.forSession('new-session'); + const sessionResource = LocalChatSessionUri.forSession('progress-session'); const mockModel = createMockChatModel({ sessionResource, - hasRequests: true + hasRequests: true, + requestInProgress: true }); - // Adding a session should trigger the autorun to fire onDidChange + // Add the session first mockChatService.addSession(sessionResource, mockModel); + let changeEventCount = 0; + disposables.add(provider.onDidChangeChatSessionItems(() => { + changeEventCount++; + })); + + // Simulate progress change by triggering the progress listener + mockChatSessionsService.triggerProgressEvent(); + assert.strictEqual(changeEventCount, 1); }); }); - test('should fire onDidChange when a model is removed via chatModels observable', async () => { + test('should fire onDidChangeChatSessionItems when model request status changes', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); - const sessionResource = LocalChatSessionUri.forSession('removed-session'); + const sessionResource = LocalChatSessionUri.forSession('status-change-session'); const mockModel = createMockChatModel({ sessionResource, - hasRequests: true + hasRequests: true, + requestInProgress: false }); // Add the session first mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChange(() => { + disposables.add(provider.onDidChangeChatSessionItems(() => { changeEventCount++; })); - // Now remove the session - the observable should trigger onDidChange - mockChatService.removeSession(sessionResource); + // Simulate progress change by triggering the progress listener + mockChatSessionsService.triggerProgressEvent(); assert.strictEqual(changeEventCount, 1); }); @@ -737,16 +742,16 @@ suite('LocalAgentsSessionsProvider', () => { mockChatService.removeSession(sessionResource); // Verify the listener was cleaned up by triggering a title change - // The onDidChange from registerModelListeners cleanup should fire once - // but after that, title changes should NOT fire onDidChange + // The onDidChangeChatSessionItems from registerModelListeners cleanup should fire once + // but after that, title changes should NOT fire onDidChangeChatSessionItems let changeEventCount = 0; - disposables.add(provider.onDidChange(() => { + disposables.add(provider.onDidChangeChatSessionItems(() => { changeEventCount++; })); (mockModel as unknown as { setCustomTitle: (title: string) => void }).setCustomTitle('New Title'); - assert.strictEqual(changeEventCount, 0, 'onDidChange should NOT fire after model is removed'); + assert.strictEqual(changeEventCount, 0, 'onDidChangeChatSessionItems should NOT fire after model is removed'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts index 46828144f1bdf..d02cbf99130d7 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts @@ -5,7 +5,7 @@ import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../common/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatRequestModel, IExportableChatData, IInputModel, ISerializableChatData } from '../../common/chatModel.js'; @@ -32,9 +32,12 @@ export class MockChatModel extends Disposable implements IChatModel { }; readonly contributedChatSession = undefined; isDisposed = false; + lastRequestObs: IObservable; constructor(readonly sessionResource: URI) { super(); + this.lastRequest = undefined; + this.lastRequestObs = observableValue('lastRequest', undefined); } readonly hasRequests = false; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 4f80d2b45d7c1..fa05383536639 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -12,6 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IEditableData } from '../../../../common/views.js'; import { IChatAgentAttachmentCapabilities } from '../../common/chatAgents.js'; import { IChatModel } from '../../common/chatModel.js'; +import { IChatService } from '../../common/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionProviderOptionGroup, IChatSessionsExtensionPoint, IChatSessionsService, SessionOptionsChangedCallback } from '../../common/chatSessionsService.js'; export class MockChatSessionsService implements IChatSessionsService { @@ -41,6 +42,7 @@ export class MockChatSessionsService implements IChatSessionsService { private sessionOptions = new ResourceMap>(); private editableData = new ResourceMap(); private inProgress = new Map(); + private onChange = () => { }; // For testing: allow triggering events fireDidChangeItemsProviders(provider: IChatSessionItemProvider): void { @@ -215,11 +217,23 @@ export class MockChatSessionsService implements IChatSessionsService { return Array.from(this.contentProviders.keys()); } - registerModelProgressListener(model: IChatModel, callback: () => void): void { - // No-op implementation for testing - } - getSessionDescription(chatModel: IChatModel): string | undefined { return undefined; } + + registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable { + // Store the emitter so tests can trigger it + this.onChange = onChange; + return { + dispose: () => { + } + }; + } + + // Helper method for tests to trigger progress events + triggerProgressEvent(): void { + if (this.onChange) { + this.onChange(); + } + } }