Skip to content

Commit f5d02ff

Browse files
DonJayamanneCopilotCopilot
authored
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>
1 parent cdfcb38 commit f5d02ff

13 files changed

Lines changed: 118 additions & 31 deletions

extensions/copilot/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4677,7 +4677,7 @@
46774677
},
46784678
"github.copilot.chat.cli.lazyLoadSessionItem.enabled": {
46794679
"type": "boolean",
4680-
"default": false,
4680+
"default": true,
46814681
"markdownDescription": "%github.copilot.config.cli.lazyLoadSessionItem.enabled%",
46824682
"tags": [
46834683
"advanced"

extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const IChatSessionWorkspaceFolderService = createServiceIdentifier<IChatS
1717
*/
1818
export interface IChatSessionWorkspaceFolderService {
1919
readonly _serviceBrand: undefined;
20+
/**
21+
* Triggered when the set of changes in a session workspace folder has changed.
22+
*/
23+
onDidChangeWorkspaceFolderChanges: vscode.Event<{ sessionId: string }>;
2024
deleteTrackedWorkspaceFolder(sessionId: string): Promise<void>;
2125
/**
2226
* Track workspace folder selection for a session (for folders without git repos in multi-root workspaces)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ export const IChatSessionWorktreeService = createServiceIdentifier<IChatSessionW
5959

6060
export interface IChatSessionWorktreeService {
6161
readonly _serviceBrand: undefined;
62+
/**
63+
* Triggered when cached worktree changes for a session are invalidated and should be refreshed.
64+
*
65+
* This event does not guarantee that the underlying set of changes was updated directly; callers
66+
* should re-query {@link getWorktreeChanges} when it fires.
67+
*/
68+
onDidChangeWorktreeChanges: vscode.Event<{ sessionId: string }>;
6269

6370
createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined>;
6471

extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export class ChatSessionRepositoryTracker extends Disposable {
1616
private readonly repositories = new DisposableResourceMap();
1717

1818
constructor(
19-
private readonly sessionItemProvider: ICopilotCLIChatSessionItemProvider,
19+
// This is only required in non-controller code paths.
20+
private readonly sessionItemProvider: ICopilotCLIChatSessionItemProvider | undefined,
2021
@IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService,
2122
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
2223
@IGitService private readonly gitService: IGitService,
@@ -69,23 +70,7 @@ export class ChatSessionRepositoryTracker extends Disposable {
6970
}
7071

7172
private async onDidChangeRepositoryState(uri: vscode.Uri): Promise<void> {
72-
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Repository state changed for ${uri.toString()}. Updating session properties.`);
73-
74-
const sessionIds = await this.metadataStore.getSessionIdsForFolder(uri);
75-
const workspaceSessionIds = this.workspaceFolderService.clearWorkspaceChanges(uri);
76-
sessionIds.push(...workspaceSessionIds);
77-
await Promise.all(Array.from(new Set(sessionIds)).map(async sessionId => {
78-
// Worktree
79-
const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId);
80-
if (worktreeProperties) {
81-
await this.worktreeService.setWorktreeProperties(sessionId, {
82-
...worktreeProperties,
83-
changes: undefined
84-
});
85-
}
86-
}));
87-
await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds });
88-
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`);
73+
await clearChangesCacheForAffectedSessions(uri, [], this.logService, this.metadataStore, this.workspaceFolderService, this.worktreeService, this.sessionItemProvider);
8974
}
9075

9176
private disposeRepositoryWatcher(uri: vscode.Uri): void {
@@ -102,3 +87,32 @@ export class ChatSessionRepositoryTracker extends Disposable {
10287
super.dispose();
10388
}
10489
}
90+
91+
/**
92+
* Invalidates the cache for sessions affected by a repository change, and triggers a refresh of those sessions.
93+
* You can optionally provide a list of sessions that should not be refreshed.
94+
* 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.
95+
*/
96+
export async function clearChangesCacheForAffectedSessions(folder: vscode.Uri, sessionsToIgnore: string[], logService: ILogService, metadataStore: IChatSessionMetadataStore, workspaceFolderService: IChatSessionWorkspaceFolderService, worktreeService: IChatSessionWorktreeService, sessionItemProvider?: ICopilotCLIChatSessionItemProvider): Promise<void> {
97+
logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Repository state changed for ${folder.toString()}. Updating session properties.`);
98+
99+
const sessionIds = metadataStore.getSessionIdsForFolder(folder).filter(id => !sessionsToIgnore.includes(id));
100+
const workspaceSessionIds = workspaceFolderService.clearWorkspaceChanges(folder).filter(id => !sessionsToIgnore.includes(id));
101+
sessionIds.forEach(id => workspaceFolderService.clearWorkspaceChanges(id));
102+
sessionIds.push(...workspaceSessionIds);
103+
await Promise.all(Array.from(new Set(sessionIds)).map(async sessionId => {
104+
// Worktree
105+
const worktreeProperties = await worktreeService.getWorktreeProperties(sessionId);
106+
if (worktreeProperties) {
107+
await worktreeService.setWorktreeProperties(sessionId, {
108+
...worktreeProperties,
109+
changes: undefined
110+
});
111+
}
112+
}));
113+
// Will be passed in non-controller code paths.
114+
if (sessionItemProvider) {
115+
await sessionItemProvider.refreshSession({ reason: 'update', sessionIds });
116+
}
117+
logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${folder.toString()}.`);
118+
}

extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh
2727
declare _serviceBrand: undefined;
2828

2929
private static readonly EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
30+
private readonly _onDidChangeWorkspaceFolderChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());
31+
readonly onDidChangeWorkspaceFolderChanges = this._onDidChangeWorkspaceFolderChanges.event;
3032

3133
private readonly workspaceState = new Map<string, WorkspaceFolderEntry>();
3234
private readonly sessionRepoKeys = new Map<string, string>();
@@ -311,6 +313,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh
311313
if (repoKey) {
312314
this.workspaceFolderChanges.delete(repoKey);
313315
}
316+
this._onDidChangeWorkspaceFolderChanges.fire({ sessionId });
314317
}
315318

316319
getAssociatedSessions(folderUri: vscode.Uri): string[] {

extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
2929
declare _serviceBrand: undefined;
3030

3131
private _sessionWorktrees: Map<string, string | ChatSessionWorktreeProperties> = new Map();
32-
32+
private readonly _onDidChangeWorktreeChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());
33+
readonly onDidChangeWorktreeChanges = this._onDidChangeWorktreeChanges.event;
3334
constructor(
3435
@IAgentSessionsWorkspace private readonly agentSessionsWorkspace: IAgentSessionsWorkspace,
3536
@IConfigurationService private readonly configurationService: IConfigurationService,
@@ -189,6 +190,10 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
189190
async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {
190191
this._sessionWorktrees.set(sessionId, properties);
191192
await this.metadataStore.storeWorktreeInfo(sessionId, properties);
193+
// If we're explicitly clearing the changes.
194+
if ('changes' in properties && !properties.changes) {
195+
this._onDidChangeWorktreeChanges.fire({ sessionId });
196+
}
192197
}
193198

194199
async getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined> {

extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
198198
));
199199

200200
const copilotcliChatSessionContentProvider = copilotcliAgentInstaService.createInstance(CopilotCLIChatSessionContentProvider);
201-
this._register(copilotcliAgentInstaService.createInstance(ChatSessionRepositoryTracker, copilotcliChatSessionContentProvider));
201+
this._register(copilotcliAgentInstaService.createInstance(ChatSessionRepositoryTracker, undefined));
202202
const promptResolver = copilotcliAgentInstaService.createInstance(CopilotCLIPromptResolver);
203203
const gitService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IGitService));
204204
const sessionTracker = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker));

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
145145
@IChatSessionWorkspaceFolderService private readonly _workspaceFolderService: IChatSessionWorkspaceFolderService,
146146
@IChatSessionMetadataStore private readonly _metadataStore: IChatSessionMetadataStore,
147147
@IWorkspaceService private readonly _workspaceService: IWorkspaceService,
148+
@IChatSessionWorktreeService chatSessionWorktreeService: IChatSessionWorktreeService,
148149
) {
149150
super();
150151

@@ -180,6 +181,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
180181
void refreshSessions();
181182
}
182183
}));
184+
this._register(this._workspaceFolderService.onDidChangeWorkspaceFolderChanges(e => {
185+
this.refreshSession({ reason: 'update', sessionId: e.sessionId });
186+
}));
187+
this._register(chatSessionWorktreeService.onDidChangeWorktreeChanges(e => {
188+
this.refreshSession({ reason: 'update', sessionId: e.sessionId });
189+
}));
183190
controller.newChatSessionItemHandler = async (context) => {
184191
const sessionId = this.sessionService.createNewSessionId();
185192
const resource = SessionIdForCLI.getResource(sessionId);
@@ -217,7 +224,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
217224
controller.resolveChatSessionItem = async (item, token) => {
218225
const sessionId = SessionIdForCLI.parse(item.resource);
219226
const session = await this.sessionService.getSessionItem(sessionId, token);
220-
if (!session || token.isCancellationRequested || Array.isArray(item.changes)) {
227+
if (!session || token.isCancellationRequested) {
221228
return;
222229
}
223230
const updatedItem = await this.toChatSessionItem(session, { includeChanges: true }, token);
@@ -228,15 +235,18 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
228235
controller.items.delete(SessionIdForCLI.getResource(e));
229236
}));
230237
this._register(this.sessionService.onDidChangeSession(async (e) => {
231-
const item = await this.toChatSessionItem(e);
238+
// Push path: VS Code uses the item we provide as source of truth and does not
239+
// re-invoke `resolveChatSessionItem` for already-visible rows. Include changes
240+
// eagerly so the visible row reflects the latest diff info.
241+
const item = await this.toChatSessionItem(e, { includeChanges: true });
232242
controller.items.add(item);
233243
}));
234244
this._register(this.sessionService.onDidCreateSession(async (e) => {
235245
const resource = SessionIdForCLI.getResource(e.id);
236246
if (controller.items.get(resource)) {
237247
return;
238248
}
239-
const item = await this.toChatSessionItem(e);
249+
const item = await this.toChatSessionItem(e, { includeChanges: true });
240250
controller.items.add(item);
241251
}));
242252

@@ -323,14 +333,16 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
323333
await Promise.allSettled(refreshOptions.sessionIds.map(async sessionId => {
324334
const item = await this.sessionService.getSessionItem(sessionId, CancellationToken.None);
325335
if (item) {
326-
const chatSessionItem = await this.toChatSessionItem(item);
336+
// Push path — include changes eagerly (see `onDidChangeSession`).
337+
const chatSessionItem = await this.toChatSessionItem(item, { includeChanges: true });
327338
this.controller.items.add(chatSessionItem);
328339
}
329340
}));
330341
} else {
331342
const item = await this.sessionService.getSessionItem(refreshOptions.sessionId, CancellationToken.None);
332343
if (item) {
333-
const chatSessionItem = await this.toChatSessionItem(item);
344+
// Push path — include changes eagerly (see `onDidChangeSession`).
345+
const chatSessionItem = await this.toChatSessionItem(item, { includeChanges: true });
334346
this.controller.items.add(chatSessionItem);
335347
}
336348
}
@@ -359,7 +371,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
359371
if (token.isCancellationRequested) {
360372
return item;
361373
}
362-
363374
// We need to get an updated version of worktree properties here because when the
364375
// changes are being computed, the worktree properties are also updated with the
365376
// repository state which we are passing along through the metadata

extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions';
5555
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
5656
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
5757
import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences';
58+
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
5859

5960
const REPOSITORY_OPTION_ID = 'repository';
6061

@@ -170,6 +171,14 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
170171

171172
private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());
172173
public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event;
174+
/**
175+
* Session ids that were targeted by an explicit `refreshSession(...)` call and have not yet been
176+
* re-provided. The next `provideChatSessionItems` pass eagerly includes `changes` for these
177+
* sessions so the visible row reflects the latest diff info — VS Code uses the items returned
178+
* from `provideChatSessionItems` as source of truth and does not re-invoke `resolveChatSessionItem`
179+
* for already-visible rows. The set is cleared after each `provideChatSessionItems` call.
180+
*/
181+
private readonly pendingChangeIncludeIds = new Set<string>();
173182

174183
public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise<vscode.ChatSessionItem | undefined>;
175184

@@ -199,7 +208,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
199208
this.resolveChatSessionItem = async (item: vscode.ChatSessionItem, token: vscode.CancellationToken): Promise<vscode.ChatSessionItem | undefined> => {
200209
const sessionId = SessionIdForCLI.parse(item.resource);
201210
const session = await this.copilotcliSessionService.getSessionItem(sessionId, token);
202-
if (!session || token.isCancellationRequested || Array.isArray(item.changes)) {
211+
if (!session || token.isCancellationRequested) {
203212
return undefined;
204213
}
205214
return this.toChatSessionItem(session, { includeChanges: true }, token);
@@ -237,6 +246,17 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
237246

238247
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise<void> {
239248
await this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });
249+
if (refreshOptions.reason === 'update') {
250+
// Mark the targeted sessions so the next `provideChatSessionItems` pass includes
251+
// fresh `changes` for them (push path equivalent — see `pendingChangeIncludeIds`).
252+
if ('sessionIds' in refreshOptions) {
253+
for (const id of refreshOptions.sessionIds) {
254+
this.pendingChangeIncludeIds.add(id);
255+
}
256+
} else {
257+
this.pendingChangeIncludeIds.add(refreshOptions.sessionId);
258+
}
259+
}
240260
this._onDidChangeChatSessionItems.fire();
241261
}
242262

@@ -247,7 +267,15 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
247267
public async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {
248268
const stopwatch = new StopWatch();
249269
const sessions = await this.copilotcliSessionService.getAllSessions(token);
250-
const diskSessions = await Promise.all(sessions.map(async session => this.toChatSessionItem(session)));
270+
// Drain the pending set: sessions that were explicitly refreshed get `changes` populated
271+
// eagerly so the visible row reflects the latest diff info on this re-provide pass.
272+
const pendingIds = new Set(this.pendingChangeIncludeIds);
273+
this.pendingChangeIncludeIds.clear();
274+
const diskSessions = await Promise.all(sessions.map(async session => this.toChatSessionItem(
275+
session,
276+
pendingIds.has(session.id) ? { includeChanges: true } : undefined,
277+
token,
278+
)));
251279

252280
const count = diskSessions.length;
253281
void this.commandExecutionService.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0);
@@ -302,7 +330,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
302330
let changes: vscode.ChatSessionChangedFile[] | undefined;
303331
if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) {
304332
changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
305-
306333
// We need to get an updated version of worktree properties here because when the
307334
// changes are being computed, the worktree properties are also updated with the
308335
// repository state which we are passing along through the metadata
@@ -1656,6 +1683,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
16561683
// is used if worktree isolation is enabled, and auto-commit is disabled or workspace
16571684
// isolation is enabled.
16581685
await this.copilotCLIWorktreeCheckpointService.handleRequestCompleted(session.sessionId, request.id);
1686+
if (workingDirectory) {
1687+
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'));
1688+
}
16591689
}
16601690

16611691
void this.handlePullRequestCreated(session).catch(ex => this.logService.error(ex, 'Failed to handle pull request creation'));

extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWork
1313
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
1414
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';
1515
import { IPullRequestDetectionService } from './pullRequestDetectionService';
16+
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
1617

1718
export interface ISessionRequestLifecycle {
1819
readonly _serviceBrand: undefined;
@@ -133,6 +134,11 @@ export class SessionRequestLifecycle extends Disposable implements ISessionReque
133134
// is used if worktree isolation is enabled, and auto-commit is disabled or workspace
134135
// isolation is enabled.
135136
await this.checkpointService.handleRequestCompleted(sessionId, request.id);
137+
138+
// Clear the changes (diff) cache for sessions associated with the same folder.
139+
if (workingDirectory) {
140+
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'));
141+
}
136142
}
137143

138144
this.prDetectionService.handlePullRequestCreated(sessionId, session.createdPullRequestUrl);

0 commit comments

Comments
 (0)