feat(copilot): enable lazy loading for chat session items and update related configurations (#312047)

* feat(copilot): enable lazy loading for chat session items and update related configurations

* updates
This commit is contained in:
Don Jayamanne
2026-04-23 16:34:22 +10:00
committed by GitHub
parent febc09500e
commit c385fd3dde
15 changed files with 142 additions and 34 deletions
@@ -59,4 +59,6 @@ export interface IChatSessionWorkspaceFolderService {
* Returns the affected session IDs.
*/
clearWorkspaceChanges(folderUri: vscode.Uri): string[];
hasCachedChanges(sessionId: string): Promise<boolean>;
}
@@ -75,7 +75,7 @@ export interface IChatSessionWorktreeService {
getWorktreeChanges(sessionId: string): Promise<readonly vscode.ChatSessionChangedFile[] | undefined>;
hasWorktreeChanges(sessionId: string): Promise<boolean>;
hasCachedChanges(sessionId: string): Promise<boolean>;
handleRequestCompleted(sessionId: string): Promise<void>;
@@ -103,6 +103,12 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh
this.invalidateSessionCache(sessionId);
}
async hasCachedChanges(sessionId: string): Promise<boolean> {
const existingRepoKey = this.sessionRepoKeys.get(sessionId);
const cachedChanges = existingRepoKey ? this.workspaceFolderChanges.get(existingRepoKey) : undefined;
return !!cachedChanges;
}
async getWorkspaceChanges(sessionId: string): Promise<readonly ChatSessionWorktreeFile[] | undefined> {
return this.workspaceChangesSequencer.queue(sessionId, async () => {
@@ -318,7 +318,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
}
}
async hasWorktreeChanges(sessionId: string): Promise<boolean> {
async hasCachedChanges(sessionId: string): Promise<boolean> {
const worktreeProperties = await this.getWorktreeProperties(sessionId);
if (!worktreeProperties || typeof worktreeProperties === 'string') {
return false;
@@ -131,7 +131,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
private readonly controller: vscode.ChatSessionItemController;
private readonly newSessions = new ResourceMap<vscode.ChatSessionItem>();
private readonly previouslyCachedChanges = new Map<string, vscode.ChatSessionChangedFile[]>();
constructor(
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
@@ -228,7 +227,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
this._register(this.sessionService.onDidDeleteSession(async (e) => {
controller.items.delete(SessionIdForCLI.getResource(e));
this.previouslyCachedChanges.delete(e);
}));
this._register(this.sessionService.onDidChangeSession(async (e) => {
const item = await this.toChatSessionItem(e);
@@ -318,7 +316,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
if (refreshOptions.reason === 'delete') {
const uri = SessionIdForCLI.getResource(refreshOptions.sessionId);
this.controller.items.delete(uri);
this.previouslyCachedChanges.delete(refreshOptions.sessionId);
} else if (refreshOptions.reason === 'update' && hasKey(refreshOptions, { 'sessionIds': true })) {
await Promise.allSettled(refreshOptions.sessionIds.map(async sessionId => {
const item = await this.sessionService.getSessionItem(sessionId, CancellationToken.None);
@@ -351,8 +348,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
item.timing = session.timing;
item.status = session.status ?? vscode.ChatSessionStatus.Completed;
// This way, when user refreshes everything, they get the cached changes immediately.
item.changes = this.previouslyCachedChanges.get(session.id);
// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
@@ -372,7 +367,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
item.changes = changes;
this.previouslyCachedChanges.set(session.id, changes);
}
if (token.isCancellationRequested) {
@@ -420,11 +414,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
if (!worktreeProperties?.repositoryPath) {
return false;
}
const [trusted, available] = await Promise.all([
const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)),
this.copilotCLIWorktreeManagerService.hasWorktreeChanges(sessionId)
this.copilotCLIWorktreeManagerService.hasCachedChanges(sessionId),
this._workspaceFolderService.hasCachedChanges(sessionId)
]);
return trusted && available;
return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges);
}
private async buildChanges(
@@ -170,7 +170,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());
public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event;
private readonly previouslyCachedChanges = new Map<string, vscode.ChatSessionChangedFile[]>();
public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise<vscode.ChatSessionItem | undefined>;
@@ -297,10 +296,9 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
// But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
let changes: vscode.ChatSessionChangedFile[] | undefined = this.previouslyCachedChanges.get(session.id);
let changes: vscode.ChatSessionChangedFile[] | undefined;
if (!token.isCancellationRequested && (options?.includeChanges || (await this.canBuildChangesFast(session.id, worktreeProperties)))) {
changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
this.previouslyCachedChanges.set(session.id, changes);
// We need to get an updated version of worktree properties here because when the
// changes are being computed, the worktree properties are also updated with the
@@ -415,11 +413,12 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
if (!worktreeProperties?.repositoryPath) {
return false;
}
const [trusted, available] = await Promise.all([
const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)),
this.worktreeManager.hasWorktreeChanges(sessionId)
this.worktreeManager.hasCachedChanges(sessionId),
this.workspaceFolderService.hasCachedChanges(sessionId)
]);
return trusted && available;
return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges);
}
+2 -1
View File
@@ -280,6 +280,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
async getRepositoryProperties() { return undefined; },
async handleRequestCompleted() { },
async getWorkspaceChanges() { return undefined; },
async hasCachedChanges() { return false; },
clearWorkspaceChanges() { return []; },
} as IChatSessionWorkspaceFolderService);
testingServiceCollection.define(IChatSessionWorktreeService, {
@@ -298,7 +299,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
async handleRequestCompletedForWorktree() { },
async cleanupWorktreeOnArchive() { return { cleaned: false }; },
async recreateWorktreeOnUnarchive() { return { recreated: false }; },
async hasWorktreeChanges() { return false; },
async hasCachedChanges() { return false; },
} as IChatSessionWorktreeService);
testingServiceCollection.define(IPromptVariablesService, new SyncDescriptor(NullPromptVariablesService));
testingServiceCollection.define(IChatDebugFileLoggerService, new NullChatDebugFileLoggerService());
@@ -71,6 +71,8 @@ Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerDefaultCon
'github.copilot.chat.cli.autoCommit.enabled': false,
'github.copilot.chat.cli.branchSupport.enabled': true,
'github.copilot.chat.cli.isolationOption.enabled': true,
'github.copilot.chat.cli.sessionController.enabled': false,
'github.copilot.chat.cli.lazyLoadSessionItem.enabled': false,
'github.copilot.chat.cli.mcp.enabled': true,
'github.copilot.chat.cli.remote.enabled': false,
'github.copilot.chat.githubMcpServer.enabled': true,
@@ -41,6 +41,7 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js';
import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js';
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { ISessionsListModelService } from './sessionsListModelService.js';
import { IAgentHostFilterService } from '../../../remoteAgentHost/common/agentHostFilter.js';
@@ -173,6 +174,7 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
private readonly contextKeyService: IContextKeyService,
private readonly markdownRendererService: IMarkdownRendererService,
private readonly hoverService: IHoverService,
private readonly agentSessionsService: IAgentSessionsService,
) { }
renderTemplate(container: HTMLElement): ISessionItemTemplate {
@@ -213,6 +215,12 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
private renderSession(element: ISession, template: ISessionItemTemplate, matches?: IMatch[]): void {
template.elementDisposables.clear();
// Trigger lazy resolve for expensive session properties (e.g. changes)
// so that providers which populate them on demand deliver fresh data
// by the time the row renders. Only fires for sessions that become
// visible in the viewport (O(visible rows), not O(all sessions)).
this.agentSessionsService.model.observeSession(element.resource);
// Toolbar context
template.titleToolbar.context = element;
@@ -717,6 +725,7 @@ export class SessionsList extends Disposable implements ISessionsList {
const approvalModel = this._register(instantiationService.createInstance(AgentSessionApprovalModel));
const markdownRendererService = instantiationService.invokeFunction(accessor => accessor.get(IMarkdownRendererService));
const hoverService = instantiationService.invokeFunction(accessor => accessor.get(IHoverService));
const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService));
const sessionRenderer = new SessionItemRenderer(
{ grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s) },
approvalModel,
@@ -724,6 +733,7 @@ export class SessionsList extends Disposable implements ISessionsList {
contextKeyService,
markdownRendererService,
hoverService,
agentSessionsService,
);
const showMoreRenderer = new SessionShowMoreRenderer();
@@ -11,6 +11,7 @@ import { IContextKey, IContextKeyService } from '../../../../platform/contextkey
import { ILogService } from '../../../../platform/log/common/log.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionArchivedContext, IsActiveSessionBackgroundProviderContext, IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../common/contextkeys.js';
import { ActiveSessionSupportsMultiChatContext, IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js';
@@ -57,6 +58,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
@ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService,
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
) {
super();
@@ -380,6 +382,13 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
if (session) {
this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}`);
// Trigger lazy resolve for expensive session properties (e.g. changes,
// badge). This is fire-and-forget — the resolve result flows back through
// the model's onDidChangeSessions → _refreshSessionCache → adapter.update()
// chain, updating observables reactively. Safe for providers without a
// resolve handler (returns undefined).
this.agentSessionsService.model.observeSession(session.resource);
} else {
this.logService.trace('[ActiveSessionService] Active session cleared');
}
@@ -11,6 +11,7 @@ import { Codicon } from '../../../../../base/common/codicons.js';
import { fromNow, getDurationString } from '../../../../../base/common/date.js';
import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { autorun } from '../../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { localize } from '../../../../../nls.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
@@ -21,6 +22,7 @@ import { ChatViewModel } from '../../common/model/chatViewModel.js';
import { IChatWidgetService } from '../chat.js';
import { ChatListWidget } from '../widget/chatListWidget.js';
import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js';
import { IAgentSessionsService } from './agentSessionsService.js';
import { AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession } from './agentSessionsModel.js';
import './media/agentSessionHoverWidget.css';
@@ -44,6 +46,7 @@ export class AgentSessionHoverWidget extends Disposable {
@IChatService private readonly chatService: IChatService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
) {
super();
@@ -178,21 +181,37 @@ export class AgentSessionHoverWidget extends Disposable {
dom.append(detailsRow, dom.$('span', undefined, fromNow(startTime, true, true)));
}
// Diff information
const diff = getAgentChangesSummary(session.changes);
if (diff && hasValidDiff(session.changes)) {
dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
const diffContainer = dom.append(detailsRow, dom.$('.agent-session-hover-diff'));
if (diff.files > 0) {
dom.append(diffContainer, dom.$('span', undefined, diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files)));
// Diff information - rendered reactively because `changes` may be lazily
// resolved by the provider (see IAgentSessionsModel.observeSession). We
// reserve a separator + container slot here and update them whenever the
// observed session emits a fresh value.
const diffSeparator = dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
const diffContainer = dom.append(detailsRow, dom.$('.agent-session-hover-diff'));
diffSeparator.style.display = 'none';
diffContainer.style.display = 'none';
const observed = this.agentSessionsService.model.observeSession(session.resource);
this._register(autorun(reader => {
const latest = observed.read(reader) ?? session;
const diff = getAgentChangesSummary(latest.changes);
dom.clearNode(diffContainer);
if (diff && hasValidDiff(latest.changes)) {
diffSeparator.style.display = '';
diffContainer.style.display = '';
if (diff.files > 0) {
dom.append(diffContainer, dom.$('span', undefined, diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files)));
}
if (diff.insertions > 0) {
dom.append(diffContainer, dom.$('span.insertions', undefined, `+${diff.insertions}`));
}
if (diff.deletions > 0) {
dom.append(diffContainer, dom.$('span.deletions', undefined, `-${diff.deletions}`));
}
} else {
diffSeparator.style.display = 'none';
diffContainer.style.display = 'none';
}
if (diff.insertions > 0) {
dom.append(diffContainer, dom.$('span.insertions', undefined, `+${diff.insertions}`));
}
if (diff.deletions > 0) {
dom.append(diffContainer, dom.$('span.deletions', undefined, `-${diff.deletions}`));
}
}
}));
// Status (only show if not completed)
if (session.status !== AgentSessionStatus.Completed) {
@@ -9,9 +9,10 @@ import { Codicon } from '../../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../../base/common/map.js';
import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js';
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
import { safeStringify } from '../../../../../base/common/objects.js';
import { derived, IObservable, observableSignalFromEvent } from '../../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
@@ -47,6 +48,19 @@ export interface IAgentSessionsModel {
readonly sessions: IAgentSession[];
getSession(resource: URI): IAgentSession | undefined;
/**
* Returns an observable that emits the latest {@link IAgentSession} for the
* given resource (or `undefined` if no session is currently known).
*
* The observable updates whenever the underlying session collection changes.
* The first call for a given resource lazily triggers
* {@link IChatSessionsService.resolveChatSessionItem} so consumers reading
* lazy properties (e.g. `changes`) see fresh values once the provider has
* resolved them. In-flight resolves are deduplicated by the chat sessions
* service.
*/
observeSession(resource: URI): IObservable<IAgentSession | undefined>;
resolve(provider: string | string[] | undefined): Promise<void>;
}
@@ -527,6 +541,35 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
return this._sessions.get(resource);
}
private _changedSignal: IObservable<void> | undefined;
private readonly _sessionObservables = new ResourceMap<IObservable<IAgentSession | undefined>>();
private readonly _resolvedResources = new ResourceSet();
observeSession(resource: URI): IObservable<IAgentSession | undefined> {
let observable = this._sessionObservables.get(resource);
if (!observable) {
// Lazily trigger a resolve for this resource so consumers reading
// lazy properties (e.g. `changes`) get fresh data without needing
// to wait for a tree row to scroll into view. The chat sessions
// service deduplicates in-flight resolves by resource.
if (!this._resolvedResources.has(resource)) {
this._resolvedResources.add(resource);
const sessionType = getChatSessionType(resource);
this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None)
.catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`));
}
this._changedSignal ??= observableSignalFromEvent('agentSessionsChanged', this.onDidChangeSessions);
const signal = this._changedSignal;
observable = derived(reader => {
signal.read(reader);
return this._sessions.get(resource);
});
this._sessionObservables.set(resource, observable);
}
return observable;
}
async resolve(provider: string | string[] | undefined): Promise<void> {
const providers = Array.isArray(provider)
? provider
@@ -51,6 +51,8 @@ import { defaultButtonStyles } from '../../../../../platform/theme/browser/defau
import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js';
import { BugIndicatingError } from '../../../../../base/common/errors.js';
import { compareIgnoreCase } from '../../../../../base/common/strings.js';
import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';
import { IChatSessionsService } from '../../common/chatSessionsService.js';
export type AgentSessionListItem = IAgentSession | IAgentSessionSection | IAgentSessionShowMore | IAgentSessionShowLess;
@@ -128,6 +130,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
@IHoverService private readonly hoverService: IHoverService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
) {
super();
}
@@ -310,6 +313,18 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
if (this._approvalModel) {
this.renderApprovalRow(session, template);
}
// Lazily resolve item details (timing, changes, badge, etc.)
this.triggerResolve(session, template);
}
private triggerResolve(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
const cts = new CancellationTokenSource();
template.elementDisposable.add({ dispose() { cts.dispose(true); } });
this.chatSessionsService.resolveChatSessionItem(session.element.providerType, session.element.resource, cts.token).catch(() => {
// Resolve failures are non-fatal — the item continues to display with whatever data is available
});
}
private renderBadge(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {
@@ -166,6 +166,12 @@ class AgentSessionReadyContribution extends Disposable implements IWorkbenchCont
return;
}
// Trigger a lazy resolve so providers that populate `changes` on
// demand (see IAgentSessionsModel.observeSession) deliver fresh data.
// Re-evaluation happens via the onDidChangeSessions listener in the
// constructor.
this.agentSessionsService.model.observeSession(sessionResource);
// Check if this is a projection-capable provider
if (!AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS.has(session.providerType)) {
this._clearEntriesWatcher();
@@ -125,12 +125,13 @@ suite('AgentSessionsDataSource', () => {
sessions,
resolved: true,
getSession: () => undefined,
observeSession: () => { throw new Error('Not implemented'); },
onWillResolve: Event.None as Event<string>,
onDidResolve: Event.None as Event<string>,
onDidChangeSessions: Event.None,
onDidChangeSessionArchivedState: Event.None,
resolve: async () => { },
};
} satisfies IAgentSessionsModel;
}
function createMockFilter(options: {