Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Emitter, Event } from '../../../../base/common/event.js';
7
import { Disposable } from '../../../../base/common/lifecycle.js';
8
import { URI } from '../../../../base/common/uri.js';
9
import { IRange } from '../../../../editor/common/core/range.js';
10
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
11
import { generateUuid } from '../../../../base/common/uuid.js';
12
import { isEqual } from '../../../../base/common/resources.js';
13
import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
14
import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
15
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
16
import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js';
17
import { changeMatchesResource, IAgentFeedbackContext } from './agentFeedbackEditorUtils.js';
18
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
19
import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
20
import { ICommandService } from '../../../../platform/commands/common/commands.js';
21
import { ILogService } from '../../../../platform/log/common/log.js';
22
import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js';
23
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
24
import { logChangesViewReviewCommentAdded } from '../../../common/sessionsTelemetry.js';
25
import { ISessionFileChange } from '../../../services/sessions/common/session.js';
26
27
// --- Types --------------------------------------------------------------------
28
29
export interface IAgentFeedback {
30
readonly id: string;
31
readonly text: string;
32
readonly resourceUri: URI;
33
readonly range: IRange;
34
readonly sessionResource: URI;
35
readonly suggestion?: ICodeReviewSuggestion;
36
readonly codeSelection?: string;
37
readonly diffHunks?: string;
38
/** When this feedback was converted from a PR review comment, the original thread ID. */
39
readonly sourcePRReviewCommentId?: string;
40
}
41
42
export interface INavigableSessionComment {
43
readonly id: string;
44
}
45
46
export interface IAgentFeedbackChangeEvent {
47
readonly sessionResource: URI;
48
readonly feedbackItems: readonly IAgentFeedback[];
49
}
50
51
export interface IAgentFeedbackNavigationBearing {
52
readonly activeIdx: number;
53
readonly totalCount: number;
54
}
55
56
// --- Service Interface --------------------------------------------------------
57
58
export const IAgentFeedbackService = createDecorator<IAgentFeedbackService>('agentFeedbackService');
59
60
export interface IAgentFeedbackService {
61
readonly _serviceBrand: undefined;
62
63
readonly onDidChangeFeedback: Event<IAgentFeedbackChangeEvent>;
64
readonly onDidChangeNavigation: Event<URI>;
65
66
/**
67
* Add a feedback item for the given session.
68
*/
69
addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback;
70
71
/**
72
* Remove a single feedback item.
73
*/
74
removeFeedback(sessionResource: URI, feedbackId: string): void;
75
76
/**
77
* Update the text of an existing feedback item.
78
*/
79
updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void;
80
81
/**
82
* Get all feedback items for a session.
83
*/
84
getFeedback(sessionResource: URI): readonly IAgentFeedback[];
85
86
/**
87
* Resolve the most recently updated session that has feedback for a given resource.
88
*/
89
getMostRecentSessionForResource(resourceUri: URI): URI | undefined;
90
91
/**
92
* Set the navigation anchor to a specific feedback item, open its editor, and fire a navigation event.
93
*/
94
revealFeedback(sessionResource: URI, feedbackId: string): Promise<void>;
95
96
/**
97
* Open an editor for the given session comment (feedback or code-review) at its range
98
* and set it as the navigation anchor.
99
*/
100
revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise<void>;
101
102
/**
103
* Navigate to next/previous feedback item in a session.
104
*/
105
getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined;
106
getNextNavigableItem<T extends INavigableSessionComment>(sessionResource: URI, items: readonly T[], next: boolean): T | undefined;
107
setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void;
108
109
/**
110
* Get the current navigation bearings for a session.
111
*/
112
getNavigationBearing(sessionResource: URI, items?: readonly INavigableSessionComment[]): IAgentFeedbackNavigationBearing;
113
114
/**
115
* Clear all feedback items for a session (e.g., after sending).
116
*/
117
clearFeedback(sessionResource: URI): void;
118
119
/**
120
* Add a feedback item and then submit the feedback. Waits for the
121
* attachment to be updated in the chat widget before submitting.
122
*/
123
addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise<void>;
124
}
125
126
// --- Implementation -----------------------------------------------------------
127
128
export class AgentFeedbackService extends Disposable implements IAgentFeedbackService {
129
130
declare readonly _serviceBrand: undefined;
131
132
private readonly _onDidChangeFeedback = this._store.add(new Emitter<IAgentFeedbackChangeEvent>());
133
readonly onDidChangeFeedback = this._onDidChangeFeedback.event;
134
private readonly _onDidChangeNavigation = this._store.add(new Emitter<URI>());
135
readonly onDidChangeNavigation = this._onDidChangeNavigation.event;
136
137
/** sessionResource → feedback items */
138
private readonly _feedbackBySession = new Map<string, IAgentFeedback[]>();
139
private readonly _sessionUpdatedOrder = new Map<string, number>();
140
private _sessionUpdatedSequence = 0;
141
private readonly _navigationAnchorBySession = new Map<string, string>();
142
143
constructor(
144
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
145
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
146
@IEditorService private readonly _editorService: IEditorService,
147
@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,
148
@ICommandService private readonly _commandService: ICommandService,
149
@ILogService private readonly _logService: ILogService,
150
@ITelemetryService private readonly _telemetryService: ITelemetryService,
151
) {
152
super();
153
}
154
155
addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback {
156
const key = sessionResource.toString();
157
let feedbackItems = this._feedbackBySession.get(key);
158
if (!feedbackItems) {
159
feedbackItems = [];
160
this._feedbackBySession.set(key, feedbackItems);
161
}
162
163
const feedback: IAgentFeedback = {
164
id: generateUuid(),
165
text,
166
resourceUri,
167
range,
168
sessionResource,
169
suggestion,
170
codeSelection: context?.codeSelection,
171
diffHunks: context?.diffHunks,
172
sourcePRReviewCommentId,
173
};
174
175
// Insert at the correct sorted position.
176
// Files are grouped by recency: first feedback for a new file appears after
177
// all existing files. Within a file, items are sorted by startLineNumber.
178
const resourceStr = resourceUri.toString();
179
const hasExistingForFile = feedbackItems.some(f => f.resourceUri.toString() === resourceStr);
180
181
if (!hasExistingForFile) {
182
// New file — append at the end
183
feedbackItems.push(feedback);
184
} else {
185
// Find insertion point: after the last item for a different file that
186
// precedes this file's block, then within this file's block by line number.
187
let insertIdx = feedbackItems.length;
188
for (let i = 0; i < feedbackItems.length; i++) {
189
if (feedbackItems[i].resourceUri.toString() === resourceStr
190
&& feedbackItems[i].range.startLineNumber > range.startLineNumber) {
191
insertIdx = i;
192
break;
193
}
194
// If we passed the last item for this file without finding a larger
195
// line number, insert right after the file's block.
196
if (feedbackItems[i].resourceUri.toString() === resourceStr) {
197
insertIdx = i + 1;
198
}
199
}
200
feedbackItems.splice(insertIdx, 0, feedback);
201
}
202
203
this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);
204
this._onDidChangeNavigation.fire(sessionResource);
205
206
this._onDidChangeFeedback.fire({ sessionResource, feedbackItems });
207
208
logChangesViewReviewCommentAdded(this._telemetryService, {
209
hasExistingFeedback: hasExistingForFile,
210
hasSuggestion: !!suggestion,
211
isFromPRReview: !!sourcePRReviewCommentId,
212
});
213
214
return feedback;
215
}
216
217
removeFeedback(sessionResource: URI, feedbackId: string): void {
218
const key = sessionResource.toString();
219
const feedbackItems = this._feedbackBySession.get(key);
220
if (!feedbackItems) {
221
return;
222
}
223
224
const idx = feedbackItems.findIndex(f => f.id === feedbackId);
225
if (idx >= 0) {
226
feedbackItems.splice(idx, 1);
227
if (this._navigationAnchorBySession.get(key) === feedbackId) {
228
this._navigationAnchorBySession.delete(key);
229
this._onDidChangeNavigation.fire(sessionResource);
230
}
231
if (feedbackItems.length > 0) {
232
this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);
233
} else {
234
this._sessionUpdatedOrder.delete(key);
235
}
236
237
this._onDidChangeFeedback.fire({ sessionResource, feedbackItems });
238
}
239
}
240
241
updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void {
242
const key = sessionResource.toString();
243
const feedbackItems = this._feedbackBySession.get(key);
244
if (!feedbackItems) {
245
return;
246
}
247
248
const idx = feedbackItems.findIndex(f => f.id === feedbackId);
249
if (idx >= 0) {
250
const existing = feedbackItems[idx];
251
feedbackItems[idx] = {
252
...existing,
253
text: newText,
254
};
255
this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);
256
this._onDidChangeFeedback.fire({ sessionResource, feedbackItems });
257
}
258
}
259
260
getFeedback(sessionResource: URI): readonly IAgentFeedback[] {
261
return this._feedbackBySession.get(sessionResource.toString()) ?? [];
262
}
263
264
getMostRecentSessionForResource(resourceUri: URI): URI | undefined {
265
let bestSession: URI | undefined;
266
let bestSequence = -1;
267
268
for (const [, feedbackItems] of this._feedbackBySession) {
269
if (!feedbackItems.length) {
270
continue;
271
}
272
273
const candidate = feedbackItems[0].sessionResource;
274
if (!this._sessionContainsResource(candidate, resourceUri, feedbackItems)) {
275
continue;
276
}
277
278
const sequence = this._sessionUpdatedOrder.get(candidate.toString()) ?? 0;
279
if (sequence > bestSequence) {
280
bestSession = candidate;
281
bestSequence = sequence;
282
}
283
}
284
285
return bestSession;
286
}
287
288
private _sessionContainsResource(sessionResource: URI, resourceUri: URI, feedbackItems: readonly IAgentFeedback[]): boolean {
289
if (feedbackItems.some(item => isEqual(item.resourceUri, resourceUri))) {
290
return true;
291
}
292
293
for (const editingSession of this._chatEditingService.editingSessionsObs.get()) {
294
if (!isEqual(editingSession.chatSessionResource, sessionResource)) {
295
continue;
296
}
297
298
if (editingEntriesContainResource(editingSession.entries.get(), resourceUri)) {
299
return true;
300
}
301
}
302
303
const session = this._sessionsManagementService.getSession(sessionResource);
304
if (!session) {
305
return false;
306
}
307
308
const changes = session.changes.get();
309
if (changes.some(change => changeMatchesResource(change, resourceUri))) {
310
return true;
311
}
312
313
return false;
314
}
315
316
async revealFeedback(sessionResource: URI, feedbackId: string): Promise<void> {
317
const key = sessionResource.toString();
318
const feedbackItems = this._feedbackBySession.get(key);
319
const feedback = feedbackItems?.find(f => f.id === feedbackId);
320
if (!feedback) {
321
return;
322
}
323
await this.revealSessionComment(sessionResource, feedbackId, feedback.resourceUri, feedback.range);
324
}
325
326
async revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise<void> {
327
const selection = { startLineNumber: range.startLineNumber, startColumn: range.startColumn };
328
const sessionData = this._sessionsManagementService.getSession(sessionResource);
329
const sessionChange = this._getSessionChange(resourceUri, sessionData?.changes.get());
330
331
if (sessionChange?.isDeletion && sessionChange.originalUri) {
332
await this._editorService.openEditor({
333
resource: sessionChange.originalUri,
334
options: {
335
modal: {},
336
preserveFocus: false,
337
revealIfVisible: true,
338
selection,
339
}
340
});
341
} else if (sessionChange?.originalUri) {
342
await this._editorService.openEditor({
343
original: { resource: sessionChange.originalUri },
344
modified: { resource: sessionChange.modifiedUri },
345
options: {
346
modal: {},
347
preserveFocus: false,
348
revealIfVisible: true,
349
selection,
350
}
351
});
352
} else {
353
await this._editorService.openEditor({
354
resource: sessionChange?.modifiedUri ?? resourceUri,
355
options: {
356
modal: {},
357
preserveFocus: false,
358
revealIfVisible: true,
359
selection,
360
}
361
});
362
}
363
364
this.setNavigationAnchor(sessionResource, commentId);
365
}
366
367
private _getSessionChange(resourceUri: URI, changes: readonly ISessionFileChange[] | undefined): { originalUri?: URI; modifiedUri: URI; isDeletion: boolean } | undefined {
368
if (!(changes instanceof Array)) {
369
return undefined;
370
}
371
372
const matchingChange = changes.find(change => changeMatchesResource(change, resourceUri));
373
if (!matchingChange) {
374
return undefined;
375
}
376
377
if (isIChatSessionFileChange2(matchingChange)) {
378
return {
379
originalUri: matchingChange.originalUri,
380
modifiedUri: matchingChange.modifiedUri ?? matchingChange.uri,
381
isDeletion: matchingChange.modifiedUri === undefined,
382
};
383
}
384
385
return {
386
originalUri: matchingChange.originalUri,
387
modifiedUri: matchingChange.modifiedUri,
388
isDeletion: false,
389
};
390
}
391
392
getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined {
393
return this.getNextNavigableItem(sessionResource, this.getFeedback(sessionResource), next);
394
}
395
396
getNextNavigableItem<T extends INavigableSessionComment>(sessionResource: URI, items: readonly T[], next: boolean): T | undefined {
397
const key = sessionResource.toString();
398
if (!items.length) {
399
this._navigationAnchorBySession.delete(key);
400
return undefined;
401
}
402
403
const anchorId = this._navigationAnchorBySession.get(key);
404
let anchorIndex = anchorId ? items.findIndex(item => item.id === anchorId) : -1;
405
406
if (anchorIndex < 0 && !next) {
407
anchorIndex = 0;
408
}
409
410
const nextIndex = next
411
? (anchorIndex + 1) % items.length
412
: (anchorIndex - 1 + items.length) % items.length;
413
414
const item = items[nextIndex];
415
this.setNavigationAnchor(sessionResource, item.id);
416
return item;
417
}
418
419
setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void {
420
const key = sessionResource.toString();
421
if (itemId) {
422
this._navigationAnchorBySession.set(key, itemId);
423
} else {
424
this._navigationAnchorBySession.delete(key);
425
}
426
this._onDidChangeNavigation.fire(sessionResource);
427
}
428
429
getNavigationBearing(sessionResource: URI, items: readonly INavigableSessionComment[] = this._feedbackBySession.get(sessionResource.toString()) ?? []): IAgentFeedbackNavigationBearing {
430
const key = sessionResource.toString();
431
const anchorId = this._navigationAnchorBySession.get(key);
432
const activeIdx = anchorId ? items.findIndex(item => item.id === anchorId) : -1;
433
return { activeIdx, totalCount: items.length };
434
}
435
436
clearFeedback(sessionResource: URI): void {
437
const key = sessionResource.toString();
438
this._feedbackBySession.delete(key);
439
this._sessionUpdatedOrder.delete(key);
440
this._navigationAnchorBySession.delete(key);
441
this._onDidChangeNavigation.fire(sessionResource);
442
this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] });
443
}
444
445
async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise<void> {
446
this.addFeedback(sessionResource, resourceUri, range, text, suggestion, context, sourcePRReviewCommentId);
447
448
// Wait for the attachment contribution to update the chat widget's attachment model
449
const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource);
450
if (widget) {
451
const attachmentId = 'agentFeedback:' + sessionResource.toString();
452
const hasAttachment = () => widget.attachmentModel.attachments.some(a => a.id === attachmentId);
453
454
if (!hasAttachment()) {
455
await Event.toPromise(
456
Event.filter(widget.attachmentModel.onDidChange, () => hasAttachment())
457
);
458
}
459
} else {
460
this._logService.error('[AgentFeedback] addFeedbackAndSubmit: no chat widget found for session, feedback may not be submitted correctly', sessionResource.toString());
461
await new Promise(resolve => setTimeout(resolve, 100));
462
}
463
464
try {
465
await this._commandService.executeCommand('agentFeedbackEditor.action.submit');
466
} catch (err) {
467
this._logService.error('[AgentFeedback] Failed to execute submit feedback command', err);
468
}
469
}
470
}
471
472