Add event emitters for workspace folder and worktree changes (#312288)

* Add event emitters for workspace folder and worktree changes; improve cache management

Co-authored-by: Copilot <copilot@github.com>

* Update extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts

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

* Update extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts

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

* Enhance chat session item handling by eagerly including changes for refreshed sessions to improve UX

Co-authored-by: Copilot <copilot@github.com>

* Change defaults

* Fixes

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Don Jayamanne
2026-04-24 13:50:21 +10:00
committed by GitHub
parent cdfcb38406
commit f5d02ffd6d
13 changed files with 118 additions and 31 deletions

View File

@@ -4677,7 +4677,7 @@
},
"github.copilot.chat.cli.lazyLoadSessionItem.enabled": {
"type": "boolean",
"default": false,
"default": true,
"markdownDescription": "%github.copilot.config.cli.lazyLoadSessionItem.enabled%",
"tags": [
"advanced"

View File

@@ -17,6 +17,10 @@ export const IChatSessionWorkspaceFolderService = createServiceIdentifier<IChatS
*/
export interface IChatSessionWorkspaceFolderService {
readonly _serviceBrand: undefined;
/**
* Triggered when the set of changes in a session workspace folder has changed.
*/
onDidChangeWorkspaceFolderChanges: vscode.Event<{ sessionId: string }>;
deleteTrackedWorkspaceFolder(sessionId: string): Promise<void>;
/**
* Track workspace folder selection for a session (for folders without git repos in multi-root workspaces)

View File

@@ -59,6 +59,13 @@ export const IChatSessionWorktreeService = createServiceIdentifier<IChatSessionW
export interface IChatSessionWorktreeService {
readonly _serviceBrand: undefined;
/**
* Triggered when cached worktree changes for a session are invalidated and should be refreshed.
*
* This event does not guarantee that the underlying set of changes was updated directly; callers
* should re-query {@link getWorktreeChanges} when it fires.
*/
onDidChangeWorktreeChanges: vscode.Event<{ sessionId: string }>;
createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined>;

View File

@@ -16,7 +16,8 @@ export class ChatSessionRepositoryTracker extends Disposable {
private readonly repositories = new DisposableResourceMap();
constructor(
private readonly sessionItemProvider: ICopilotCLIChatSessionItemProvider,
// This is only required in non-controller code paths.
private readonly sessionItemProvider: ICopilotCLIChatSessionItemProvider | undefined,
@IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService,
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
@IGitService private readonly gitService: IGitService,
@@ -69,23 +70,7 @@ export class ChatSessionRepositoryTracker extends Disposable {
}
private async onDidChangeRepositoryState(uri: vscode.Uri): Promise<void> {
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Repository state changed for ${uri.toString()}. Updating session properties.`);
const sessionIds = await this.metadataStore.getSessionIdsForFolder(uri);
const workspaceSessionIds = this.workspaceFolderService.clearWorkspaceChanges(uri);
sessionIds.push(...workspaceSessionIds);
await Promise.all(Array.from(new Set(sessionIds)).map(async sessionId => {
// Worktree
const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId);
if (worktreeProperties) {
await this.worktreeService.setWorktreeProperties(sessionId, {
...worktreeProperties,
changes: undefined
});
}
}));
await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds });
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`);
await clearChangesCacheForAffectedSessions(uri, [], this.logService, this.metadataStore, this.workspaceFolderService, this.worktreeService, this.sessionItemProvider);
}
private disposeRepositoryWatcher(uri: vscode.Uri): void {
@@ -102,3 +87,32 @@ export class ChatSessionRepositoryTracker extends Disposable {
super.dispose();
}
}
/**
* Invalidates the cache for sessions affected by a repository change, and triggers a refresh of those sessions.
* You can optionally provide a list of sessions that should not be refreshed.
* E.g. if you know that those sessions are not affected or are already up to date, you can exclude them from the refresh to avoid unnecessary work.
*/
export async function clearChangesCacheForAffectedSessions(folder: vscode.Uri, sessionsToIgnore: string[], logService: ILogService, metadataStore: IChatSessionMetadataStore, workspaceFolderService: IChatSessionWorkspaceFolderService, worktreeService: IChatSessionWorktreeService, sessionItemProvider?: ICopilotCLIChatSessionItemProvider): Promise<void> {
logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Repository state changed for ${folder.toString()}. Updating session properties.`);
const sessionIds = metadataStore.getSessionIdsForFolder(folder).filter(id => !sessionsToIgnore.includes(id));
const workspaceSessionIds = workspaceFolderService.clearWorkspaceChanges(folder).filter(id => !sessionsToIgnore.includes(id));
sessionIds.forEach(id => workspaceFolderService.clearWorkspaceChanges(id));
sessionIds.push(...workspaceSessionIds);
await Promise.all(Array.from(new Set(sessionIds)).map(async sessionId => {
// Worktree
const worktreeProperties = await worktreeService.getWorktreeProperties(sessionId);
if (worktreeProperties) {
await worktreeService.setWorktreeProperties(sessionId, {
...worktreeProperties,
changes: undefined
});
}
}));
// Will be passed in non-controller code paths.
if (sessionItemProvider) {
await sessionItemProvider.refreshSession({ reason: 'update', sessionIds });
}
logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${folder.toString()}.`);
}

View File

@@ -27,6 +27,8 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh
declare _serviceBrand: undefined;
private static readonly EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
private readonly _onDidChangeWorkspaceFolderChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());
readonly onDidChangeWorkspaceFolderChanges = this._onDidChangeWorkspaceFolderChanges.event;
private readonly workspaceState = new Map<string, WorkspaceFolderEntry>();
private readonly sessionRepoKeys = new Map<string, string>();
@@ -311,6 +313,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh
if (repoKey) {
this.workspaceFolderChanges.delete(repoKey);
}
this._onDidChangeWorkspaceFolderChanges.fire({ sessionId });
}
getAssociatedSessions(folderUri: vscode.Uri): string[] {

View File

@@ -29,7 +29,8 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
declare _serviceBrand: undefined;
private _sessionWorktrees: Map<string, string | ChatSessionWorktreeProperties> = new Map();
private readonly _onDidChangeWorktreeChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());
readonly onDidChangeWorktreeChanges = this._onDidChangeWorktreeChanges.event;
constructor(
@IAgentSessionsWorkspace private readonly agentSessionsWorkspace: IAgentSessionsWorkspace,
@IConfigurationService private readonly configurationService: IConfigurationService,
@@ -189,6 +190,10 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {
this._sessionWorktrees.set(sessionId, properties);
await this.metadataStore.storeWorktreeInfo(sessionId, properties);
// If we're explicitly clearing the changes.
if ('changes' in properties && !properties.changes) {
this._onDidChangeWorktreeChanges.fire({ sessionId });
}
}
async getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined> {

View File

@@ -198,7 +198,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
));
const copilotcliChatSessionContentProvider = copilotcliAgentInstaService.createInstance(CopilotCLIChatSessionContentProvider);
this._register(copilotcliAgentInstaService.createInstance(ChatSessionRepositoryTracker, copilotcliChatSessionContentProvider));
this._register(copilotcliAgentInstaService.createInstance(ChatSessionRepositoryTracker, undefined));
const promptResolver = copilotcliAgentInstaService.createInstance(CopilotCLIPromptResolver);
const gitService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IGitService));
const sessionTracker = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker));

View File

@@ -145,6 +145,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
@IChatSessionWorkspaceFolderService private readonly _workspaceFolderService: IChatSessionWorkspaceFolderService,
@IChatSessionMetadataStore private readonly _metadataStore: IChatSessionMetadataStore,
@IWorkspaceService private readonly _workspaceService: IWorkspaceService,
@IChatSessionWorktreeService chatSessionWorktreeService: IChatSessionWorktreeService,
) {
super();
@@ -180,6 +181,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
void refreshSessions();
}
}));
this._register(this._workspaceFolderService.onDidChangeWorkspaceFolderChanges(e => {
this.refreshSession({ reason: 'update', sessionId: e.sessionId });
}));
this._register(chatSessionWorktreeService.onDidChangeWorktreeChanges(e => {
this.refreshSession({ reason: 'update', sessionId: e.sessionId });
}));
controller.newChatSessionItemHandler = async (context) => {
const sessionId = this.sessionService.createNewSessionId();
const resource = SessionIdForCLI.getResource(sessionId);
@@ -217,7 +224,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
controller.resolveChatSessionItem = async (item, token) => {
const sessionId = SessionIdForCLI.parse(item.resource);
const session = await this.sessionService.getSessionItem(sessionId, token);
if (!session || token.isCancellationRequested || Array.isArray(item.changes)) {
if (!session || token.isCancellationRequested) {
return;
}
const updatedItem = await this.toChatSessionItem(session, { includeChanges: true }, token);
@@ -228,7 +235,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
controller.items.delete(SessionIdForCLI.getResource(e));
}));
this._register(this.sessionService.onDidChangeSession(async (e) => {
const item = await this.toChatSessionItem(e);
// Push path: VS Code uses the item we provide as source of truth and does not
// re-invoke `resolveChatSessionItem` for already-visible rows. Include changes
// eagerly so the visible row reflects the latest diff info.
const item = await this.toChatSessionItem(e, { includeChanges: true });
controller.items.add(item);
}));
this._register(this.sessionService.onDidCreateSession(async (e) => {
@@ -236,7 +246,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
if (controller.items.get(resource)) {
return;
}
const item = await this.toChatSessionItem(e);
const item = await this.toChatSessionItem(e, { includeChanges: true });
controller.items.add(item);
}));
@@ -323,14 +333,16 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
await Promise.allSettled(refreshOptions.sessionIds.map(async sessionId => {
const item = await this.sessionService.getSessionItem(sessionId, CancellationToken.None);
if (item) {
const chatSessionItem = await this.toChatSessionItem(item);
// Push path — include changes eagerly (see `onDidChangeSession`).
const chatSessionItem = await this.toChatSessionItem(item, { includeChanges: true });
this.controller.items.add(chatSessionItem);
}
}));
} else {
const item = await this.sessionService.getSessionItem(refreshOptions.sessionId, CancellationToken.None);
if (item) {
const chatSessionItem = await this.toChatSessionItem(item);
// Push path — include changes eagerly (see `onDidChangeSession`).
const chatSessionItem = await this.toChatSessionItem(item, { includeChanges: true });
this.controller.items.add(chatSessionItem);
}
}
@@ -359,7 +371,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
if (token.isCancellationRequested) {
return item;
}
// 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
// repository state which we are passing along through the metadata

View File

@@ -55,6 +55,7 @@ import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions';
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences';
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
const REPOSITORY_OPTION_ID = 'repository';
@@ -170,6 +171,14 @@ 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;
/**
* Session ids that were targeted by an explicit `refreshSession(...)` call and have not yet been
* re-provided. The next `provideChatSessionItems` pass eagerly includes `changes` for these
* sessions so the visible row reflects the latest diff info — VS Code uses the items returned
* from `provideChatSessionItems` as source of truth and does not re-invoke `resolveChatSessionItem`
* for already-visible rows. The set is cleared after each `provideChatSessionItems` call.
*/
private readonly pendingChangeIncludeIds = new Set<string>();
public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise<vscode.ChatSessionItem | undefined>;
@@ -199,7 +208,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
this.resolveChatSessionItem = async (item: vscode.ChatSessionItem, token: vscode.CancellationToken): Promise<vscode.ChatSessionItem | undefined> => {
const sessionId = SessionIdForCLI.parse(item.resource);
const session = await this.copilotcliSessionService.getSessionItem(sessionId, token);
if (!session || token.isCancellationRequested || Array.isArray(item.changes)) {
if (!session || token.isCancellationRequested) {
return undefined;
}
return this.toChatSessionItem(session, { includeChanges: true }, token);
@@ -237,6 +246,17 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise<void> {
await this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });
if (refreshOptions.reason === 'update') {
// Mark the targeted sessions so the next `provideChatSessionItems` pass includes
// fresh `changes` for them (push path equivalent — see `pendingChangeIncludeIds`).
if ('sessionIds' in refreshOptions) {
for (const id of refreshOptions.sessionIds) {
this.pendingChangeIncludeIds.add(id);
}
} else {
this.pendingChangeIncludeIds.add(refreshOptions.sessionId);
}
}
this._onDidChangeChatSessionItems.fire();
}
@@ -247,7 +267,15 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
public async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {
const stopwatch = new StopWatch();
const sessions = await this.copilotcliSessionService.getAllSessions(token);
const diskSessions = await Promise.all(sessions.map(async session => this.toChatSessionItem(session)));
// Drain the pending set: sessions that were explicitly refreshed get `changes` populated
// eagerly so the visible row reflects the latest diff info on this re-provide pass.
const pendingIds = new Set(this.pendingChangeIncludeIds);
this.pendingChangeIncludeIds.clear();
const diskSessions = await Promise.all(sessions.map(async session => this.toChatSessionItem(
session,
pendingIds.has(session.id) ? { includeChanges: true } : undefined,
token,
)));
const count = diskSessions.length;
void this.commandExecutionService.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0);
@@ -302,7 +330,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
let changes: vscode.ChatSessionChangedFile[] | undefined;
if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) {
changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
// 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
// repository state which we are passing along through the metadata
@@ -1656,6 +1683,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
// is used if worktree isolation is enabled, and auto-commit is disabled or workspace
// isolation is enabled.
await this.copilotCLIWorktreeCheckpointService.handleRequestCompleted(session.sessionId, request.id);
if (workingDirectory) {
void clearChangesCacheForAffectedSessions(workingDirectory, [session.sessionId], this.logService, this.chatSessionMetadataStore, this.workspaceFolderService, this.copilotCLIWorktreeManagerService, this.sessionItemProvider).catch(ex => this.logService.error(ex, 'Failed to clear changes cache after request completion'));
}
}
void this.handlePullRequestCreated(session).catch(ex => this.logService.error(ex, 'Failed to handle pull request creation'));

View File

@@ -13,6 +13,7 @@ import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWork
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';
import { IPullRequestDetectionService } from './pullRequestDetectionService';
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
export interface ISessionRequestLifecycle {
readonly _serviceBrand: undefined;
@@ -133,6 +134,11 @@ export class SessionRequestLifecycle extends Disposable implements ISessionReque
// is used if worktree isolation is enabled, and auto-commit is disabled or workspace
// isolation is enabled.
await this.checkpointService.handleRequestCompleted(sessionId, request.id);
// Clear the changes (diff) cache for sessions associated with the same folder.
if (workingDirectory) {
void clearChangesCacheForAffectedSessions(workingDirectory, [sessionId], this.logService, this.metadataStore, this.workspaceFolderService, this.worktreeService).catch(ex => this.logService.error(ex, 'Failed to clear changes cache after request completion'));
}
}
this.prDetectionService.handlePullRequestCreated(sessionId, session.createdPullRequestUrl);

View File

@@ -92,11 +92,15 @@ class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
override getWorktreeProperties = vi.fn(async (_sessionId: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> => undefined);
override setWorktreeProperties = vi.fn(async () => { });
override getWorktreeChanges = vi.fn(async () => []);
override hasCachedChanges = vi.fn(async () => false);
override onDidChangeWorktreeChanges = Event.None;
}
class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
declare readonly _serviceBrand: undefined;
override getWorkspaceChanges = vi.fn(async () => []);
override hasCachedChanges = vi.fn(async () => false);
override onDidChangeWorkspaceFolderChanges = Event.None;
}
class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {
@@ -206,6 +210,7 @@ function createProvider() {
workspaceFolderService,
metadataStore,
new NullWorkspaceService(),
worktreeService,
);
return {

View File

@@ -614,7 +614,7 @@ export namespace ConfigKey {
export const CLIPlanExitModeEnabled = defineSetting<boolean>('chat.cli.planExitMode.enabled', ConfigType.Simple, true);
export const CLIAutoModelEnabled = defineSetting<boolean>('chat.cli.autoModel.enabled', ConfigType.Simple, true);
export const CLIPlanCommandEnabled = defineSetting<boolean>('chat.cli.planCommand.enabled', ConfigType.Simple, true);
export const CLIChatLazyLoadSessionItem = defineSetting<boolean>('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, false);
export const CLIChatLazyLoadSessionItem = defineSetting<boolean>('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, true);
export const CLIAIGenerateBranchNames = defineSetting<boolean>('chat.cli.aiGenerateBranchNames.enabled', ConfigType.Simple, true);
export const CLIForkSessionsEnabled = defineSetting<boolean>('chat.cli.forkSessions.enabled', ConfigType.Simple, true);
export const CLIMCPServerEnabled = defineAndMigrateSetting<boolean | undefined>('chat.advanced.cli.mcp.enabled', 'chat.cli.mcp.enabled', true);

View File

@@ -282,6 +282,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
async getWorkspaceChanges() { return undefined; },
async hasCachedChanges() { return false; },
clearWorkspaceChanges() { return []; },
onDidChangeWorkspaceFolderChanges: () => ({ dispose() { } }),
} as IChatSessionWorkspaceFolderService);
testingServiceCollection.define(IChatSessionWorktreeService, {
_serviceBrand: undefined,
@@ -300,6 +301,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
async cleanupWorktreeOnArchive() { return { cleaned: false }; },
async recreateWorktreeOnUnarchive() { return { recreated: false }; },
async hasCachedChanges() { return false; },
onDidChangeWorktreeChanges: () => ({ dispose() { } }),
} as IChatSessionWorktreeService);
testingServiceCollection.define(IPromptVariablesService, new SyncDescriptor(NullPromptVariablesService));
testingServiceCollection.define(IChatDebugFileLoggerService, new NullChatDebugFileLoggerService());