Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.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 './media/agentFeedbackEditorWidget.css';
7
8
import { Action } from '../../../../base/common/actions.js';
9
import { Codicon } from '../../../../base/common/codicons.js';
10
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
11
import { Event } from '../../../../base/common/event.js';
12
import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js';
13
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';
14
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
15
import { IEditorContribution, IEditorDecorationsCollection, ScrollType } from '../../../../editor/common/editorCommon.js';
16
import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js';
17
import { EditorOption } from '../../../../editor/common/config/editorOptions.js';
18
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
19
import { $, addDisposableListener, addStandardDisposableListener, clearNode, getTotalWidth } from '../../../../base/browser/dom.js';
20
import { URI } from '../../../../base/common/uri.js';
21
import { Range } from '../../../../editor/common/core/range.js';
22
import { overviewRulerRangeHighlight } from '../../../../editor/common/core/editorColorRegistry.js';
23
import { OverviewRulerLane } from '../../../../editor/common/model.js';
24
import { themeColorFromId } from '../../../../platform/theme/common/themeService.js';
25
import { ThemeIcon } from '../../../../base/common/themables.js';
26
import * as nls from '../../../../nls.js';
27
import { IAgentFeedbackService } from './agentFeedbackService.js';
28
import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
29
import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
30
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
31
import { createAgentFeedbackContext, getSessionForResource } from './agentFeedbackEditorUtils.js';
32
import { ICodeReviewService, IPRReviewState } from '../../codeReview/browser/codeReviewService.js';
33
import { getSessionEditorComments, groupNearbySessionEditorComments, ISessionEditorComment, SessionEditorCommentSource, toSessionEditorCommentId } from './sessionEditorComments.js';
34
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
35
import { isEqual } from '../../../../base/common/resources.js';
36
import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';
37
import { MarkdownString } from '../../../../base/common/htmlContent.js';
38
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
39
import { KeyCode } from '../../../../base/common/keyCodes.js';
40
import { ISessionFileChange } from '../../../services/sessions/common/session.js';
41
42
interface ICommentItemActions {
43
editAction: Action;
44
convertAction: Action | undefined;
45
removeAction: Action;
46
}
47
48
/**
49
* Widget that displays agent feedback comments for a group of nearby feedback items.
50
* Positioned on the right side of the editor like a speech bubble.
51
*/
52
export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWidget {
53
54
private static _idPool = 0;
55
private readonly _id: string = `agent-feedback-widget-${AgentFeedbackEditorWidget._idPool++}`;
56
57
private readonly _domNode: HTMLElement;
58
private readonly _headerNode: HTMLElement;
59
private readonly _titleNode: HTMLElement;
60
private readonly _toggleButton: HTMLElement;
61
private readonly _bodyNode: HTMLElement;
62
private readonly _itemElements = new Map<string, HTMLElement>();
63
64
private _position: IOverlayWidgetPosition | null = null;
65
private _isExpanded: boolean = false;
66
private _disposed: boolean = false;
67
private _startLineNumber: number = 1;
68
private readonly _rangeHighlightDecoration: IEditorDecorationsCollection;
69
70
private readonly _eventStore = this._register(new DisposableStore());
71
72
constructor(
73
private readonly _editor: ICodeEditor,
74
private readonly _commentItems: readonly ISessionEditorComment[],
75
private readonly _sessionResource: URI,
76
@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,
77
@ICodeReviewService private readonly _codeReviewService: ICodeReviewService,
78
@IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService,
79
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
80
) {
81
super();
82
83
this._rangeHighlightDecoration = this._editor.createDecorationsCollection();
84
85
// Create DOM structure
86
this._domNode = $('div.agent-feedback-widget');
87
this._domNode.classList.add('collapsed');
88
89
// Header
90
this._headerNode = $('div.agent-feedback-widget-header');
91
92
// Comment icon (decorative, hidden from screen readers)
93
const commentIcon = renderIcon(Codicon.comment);
94
commentIcon.setAttribute('aria-hidden', 'true');
95
this._headerNode.appendChild(commentIcon);
96
97
// Title showing feedback count
98
this._titleNode = $('span.agent-feedback-widget-title');
99
this._updateTitle();
100
this._headerNode.appendChild(this._titleNode);
101
102
// Spacer
103
this._headerNode.appendChild($('span.agent-feedback-widget-spacer'));
104
105
// Toggle expand/collapse button
106
this._toggleButton = $('div.agent-feedback-widget-toggle');
107
this._updateToggleButton();
108
this._headerNode.appendChild(this._toggleButton);
109
110
this._domNode.appendChild(this._headerNode);
111
112
// Body (collapsible) — starts collapsed
113
this._bodyNode = $('div.agent-feedback-widget-body');
114
this._bodyNode.classList.add('collapsed');
115
this._buildFeedbackItems();
116
this._domNode.appendChild(this._bodyNode);
117
118
// Arrow pointer
119
const arrow = $('div.agent-feedback-widget-arrow');
120
this._domNode.appendChild(arrow);
121
122
// Event handlers
123
this._setupEventHandlers();
124
125
// Add visible class for initial display
126
this._domNode.classList.add('visible');
127
128
// Add to editor
129
this._editor.addOverlayWidget(this);
130
}
131
132
private _setupEventHandlers(): void {
133
// Toggle button click - expand/collapse
134
this._eventStore.add(addDisposableListener(this._toggleButton, 'click', (e) => {
135
e.stopPropagation();
136
this._toggleExpanded();
137
}));
138
139
// Header click - also toggles expand/collapse
140
this._eventStore.add(addDisposableListener(this._headerNode, 'click', () => {
141
this._toggleExpanded();
142
}));
143
144
}
145
146
private _toggleExpanded(): void {
147
if (this._isExpanded) {
148
this.collapse();
149
} else {
150
this.expand();
151
}
152
}
153
154
private _updateTitle(): void {
155
const count = this._commentItems.length;
156
if (count === 1) {
157
this._titleNode.textContent = this._commentItems[0].text;
158
} else {
159
this._titleNode.textContent = nls.localize('nComments', "{0} comments", count);
160
}
161
}
162
163
private _updateToggleButton(): void {
164
clearNode(this._toggleButton);
165
if (this._isExpanded) {
166
this._toggleButton.appendChild(renderIcon(Codicon.chevronUp));
167
this._toggleButton.title = nls.localize('collapse', "Collapse");
168
} else {
169
this._toggleButton.appendChild(renderIcon(Codicon.chevronDown));
170
this._toggleButton.title = nls.localize('expand', "Expand");
171
}
172
}
173
174
private _buildFeedbackItems(): void {
175
clearNode(this._bodyNode);
176
this._itemElements.clear();
177
178
for (const comment of this._commentItems) {
179
const item = $('div.agent-feedback-widget-item');
180
item.classList.add(`agent-feedback-widget-item-${comment.source}`);
181
if (comment.suggestion) {
182
item.classList.add('agent-feedback-widget-item-suggestion');
183
}
184
this._itemElements.set(comment.id, item);
185
186
const itemHeader = $('div.agent-feedback-widget-item-header');
187
const itemMeta = $('div.agent-feedback-widget-item-meta');
188
189
const lineInfo = $('span.agent-feedback-widget-line-info');
190
if (comment.range.startLineNumber === comment.range.endLineNumber) {
191
lineInfo.textContent = nls.localize('lineNumber', "Line {0}", comment.range.startLineNumber);
192
} else {
193
lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", comment.range.startLineNumber, comment.range.endLineNumber);
194
}
195
itemMeta.appendChild(lineInfo);
196
197
if (comment.source !== SessionEditorCommentSource.AgentFeedback) {
198
const typeBadge = $('span.agent-feedback-widget-item-type');
199
typeBadge.textContent = this._getTypeLabel(comment);
200
itemMeta.appendChild(typeBadge);
201
}
202
203
itemHeader.appendChild(itemMeta);
204
205
const actionBarContainer = $('div.agent-feedback-widget-item-actions');
206
const actionBar = this._eventStore.add(new ActionBar(actionBarContainer));
207
208
const itemActions: ICommentItemActions = { editAction: undefined!, convertAction: undefined, removeAction: undefined! };
209
210
itemActions.editAction = new Action(
211
'agentFeedback.widget.edit',
212
nls.localize('editComment', "Edit"),
213
ThemeIcon.asClassName(Codicon.edit),
214
true,
215
(): void => { this._startEditing(comment, text, itemActions); },
216
);
217
actionBar.push(itemActions.editAction, { icon: true, label: false });
218
219
if (comment.canConvertToAgentFeedback) {
220
itemActions.convertAction = new Action(
221
'agentFeedback.widget.convert',
222
nls.localize('convertComment', "Convert to Agent Feedback"),
223
ThemeIcon.asClassName(Codicon.check),
224
true,
225
() => this._convertToAgentFeedback(comment),
226
);
227
actionBar.push(itemActions.convertAction, { icon: true, label: false });
228
}
229
itemActions.removeAction = new Action(
230
'agentFeedback.widget.remove',
231
nls.localize('removeComment', "Remove"),
232
ThemeIcon.asClassName(Codicon.close),
233
true,
234
() => this._removeComment(comment),
235
);
236
actionBar.push(itemActions.removeAction, { icon: true, label: false });
237
238
itemHeader.appendChild(actionBarContainer);
239
item.appendChild(itemHeader);
240
241
const text = $('div.agent-feedback-widget-text');
242
const rendered = this._markdownRendererService.render(new MarkdownString(comment.text));
243
this._eventStore.add(rendered);
244
text.appendChild(rendered.element);
245
item.appendChild(text);
246
247
if (comment.suggestion?.edits.length) {
248
item.appendChild(this._renderSuggestion(comment));
249
}
250
251
this._eventStore.add(addDisposableListener(item, 'mouseenter', () => {
252
this._highlightRange(comment);
253
}));
254
255
this._eventStore.add(addDisposableListener(item, 'mouseleave', () => {
256
this._rangeHighlightDecoration.clear();
257
}));
258
259
this._eventStore.add(addDisposableListener(item, 'click', e => {
260
if ((e.target as HTMLElement | null)?.closest('.action-bar')) {
261
return;
262
}
263
this.focusFeedback(comment.id);
264
this._agentFeedbackService.setNavigationAnchor(this._sessionResource, comment.id);
265
this._revealComment(comment);
266
}));
267
268
this._bodyNode.appendChild(item);
269
}
270
}
271
272
private _getTypeLabel(comment: ISessionEditorComment): string {
273
if (comment.source === SessionEditorCommentSource.PRReview) {
274
return nls.localize('prReviewComment', "PR Review");
275
}
276
277
if (comment.source === SessionEditorCommentSource.CodeReview) {
278
return comment.suggestion
279
? nls.localize('reviewSuggestion', "Review Suggestion")
280
: nls.localize('reviewComment', "Review");
281
}
282
283
return comment.suggestion
284
? nls.localize('feedbackSuggestion', "Feedback Suggestion")
285
: nls.localize('feedbackComment', "Feedback");
286
}
287
288
private _renderSuggestion(comment: ISessionEditorComment): HTMLElement {
289
const suggestionNode = $('div.agent-feedback-widget-suggestion');
290
291
for (const edit of comment.suggestion?.edits ?? []) {
292
const editNode = $('div.agent-feedback-widget-suggestion-edit');
293
294
const header = $('div.agent-feedback-widget-suggestion-header');
295
if (edit.range.startLineNumber === edit.range.endLineNumber) {
296
header.textContent = nls.localize('suggestedChangeLine', "Suggested Change \u2022 Line {0}", edit.range.startLineNumber);
297
} else {
298
header.textContent = nls.localize('suggestedChangeLines', "Suggested Change \u2022 Lines {0}-{1}", edit.range.startLineNumber, edit.range.endLineNumber);
299
}
300
editNode.appendChild(header);
301
302
const newText = $('pre.agent-feedback-widget-suggestion-text');
303
newText.textContent = edit.newText;
304
editNode.appendChild(newText);
305
suggestionNode.appendChild(editNode);
306
}
307
308
return suggestionNode;
309
}
310
311
private _removeComment(comment: ISessionEditorComment): void {
312
if (comment.source === SessionEditorCommentSource.PRReview) {
313
this._codeReviewService.resolvePRReviewThread(this._sessionResource!, comment.sourceId);
314
return;
315
}
316
if (comment.source === SessionEditorCommentSource.CodeReview) {
317
this._codeReviewService.removeComment(this._sessionResource, comment.sourceId);
318
return;
319
}
320
321
this._agentFeedbackService.removeFeedback(this._sessionResource, comment.sourceId);
322
}
323
324
private _startEditing(comment: ISessionEditorComment, textContainer: HTMLElement, actions: ICommentItemActions): void {
325
// Disable all actions while editing
326
actions.editAction.enabled = false;
327
if (actions.convertAction) {
328
actions.convertAction.enabled = false;
329
}
330
actions.removeAction.enabled = false;
331
332
const editStore = new DisposableStore();
333
this._eventStore.add(editStore);
334
335
clearNode(textContainer);
336
textContainer.classList.add('editing');
337
338
const textarea = $('textarea.agent-feedback-widget-edit-textarea') as HTMLTextAreaElement;
339
textarea.value = comment.text;
340
textarea.rows = 1;
341
textContainer.appendChild(textarea);
342
343
// Auto-size the textarea
344
const autoSize = () => {
345
textarea.style.height = 'auto';
346
textarea.style.height = `${textarea.scrollHeight}px`;
347
this._editor.layoutOverlayWidget(this);
348
};
349
autoSize();
350
351
editStore.add(addDisposableListener(textarea, 'input', autoSize));
352
353
editStore.add(addStandardDisposableListener(textarea, 'keydown', (e) => {
354
if (e.keyCode === KeyCode.Enter && !e.shiftKey) {
355
e.preventDefault();
356
e.stopPropagation();
357
const newText = textarea.value.trim();
358
if (newText) {
359
this._saveEdit(comment, newText);
360
}
361
// Widget will be rebuilt by the change event
362
} else if (e.keyCode === KeyCode.Escape) {
363
e.preventDefault();
364
e.stopPropagation();
365
this._stopEditing(comment, textContainer, editStore, actions);
366
}
367
}));
368
369
// Stop editing when focus is lost
370
editStore.add(addDisposableListener(textarea, 'blur', () => {
371
this._stopEditing(comment, textContainer, editStore, actions);
372
}));
373
374
textarea.focus();
375
}
376
377
private _saveEdit(comment: ISessionEditorComment, newText: string): void {
378
if (comment.source === SessionEditorCommentSource.AgentFeedback) {
379
this._agentFeedbackService.updateFeedback(this._sessionResource, comment.sourceId, newText);
380
} else {
381
// PR review and code review comments are converted to agent feedback on edit
382
this._convertToAgentFeedbackWithText(comment, newText);
383
}
384
}
385
386
private _stopEditing(comment: ISessionEditorComment, textContainer: HTMLElement, editStore: DisposableStore, actions: ICommentItemActions): void {
387
editStore.dispose();
388
389
// Re-enable actions
390
actions.editAction.enabled = true;
391
if (actions.convertAction) {
392
actions.convertAction.enabled = true;
393
}
394
actions.removeAction.enabled = true;
395
396
textContainer.classList.remove('editing');
397
clearNode(textContainer);
398
const rendered = this._markdownRendererService.render(new MarkdownString(comment.text));
399
this._eventStore.add(rendered);
400
textContainer.appendChild(rendered.element);
401
this._editor.layoutOverlayWidget(this);
402
}
403
404
private _convertToAgentFeedback(comment: ISessionEditorComment): void {
405
this._convertToAgentFeedbackWithText(comment, comment.text);
406
}
407
408
/**
409
* Converts a non-agent-feedback comment into an agent feedback item, optionally with edited text.
410
*/
411
private _convertToAgentFeedbackWithText(comment: ISessionEditorComment, text: string): void {
412
if (!comment.canConvertToAgentFeedback) {
413
return;
414
}
415
416
const sourcePRReviewCommentId = comment.source === SessionEditorCommentSource.PRReview
417
? comment.sourceId
418
: undefined;
419
420
const feedback = this._agentFeedbackService.addFeedback(
421
this._sessionResource,
422
comment.resourceUri,
423
comment.range,
424
text,
425
comment.suggestion,
426
createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range),
427
sourcePRReviewCommentId,
428
);
429
this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id));
430
if (comment.source === SessionEditorCommentSource.CodeReview) {
431
this._codeReviewService.removeComment(this._sessionResource, comment.sourceId);
432
} else if (comment.source === SessionEditorCommentSource.PRReview) {
433
this._codeReviewService.markPRReviewCommentConverted(this._sessionResource, comment.sourceId);
434
}
435
}
436
437
/**
438
* Expand the widget body.
439
*/
440
expand(): void {
441
this._isExpanded = true;
442
this._domNode.classList.remove('collapsed');
443
this._bodyNode.classList.remove('collapsed');
444
this._updateToggleButton();
445
this._editor.layoutOverlayWidget(this);
446
}
447
448
/**
449
* Collapse the widget body.
450
*/
451
collapse(): void {
452
this._isExpanded = false;
453
this._domNode.classList.add('collapsed');
454
this._bodyNode.classList.add('collapsed');
455
this._updateToggleButton();
456
this.clearFocus();
457
this._editor.layoutOverlayWidget(this);
458
}
459
460
/**
461
* Focus a specific feedback item within this widget.
462
* Highlights its range in the editor and marks it as focused.
463
*/
464
focusFeedback(feedbackId: string): void {
465
// Clear previous focus
466
for (const el of this._itemElements.values()) {
467
el.classList.remove('focused');
468
}
469
470
const feedback = this._commentItems.find(f => f.id === feedbackId);
471
if (!feedback) {
472
return;
473
}
474
475
// Add focused class to the item
476
const itemEl = this._itemElements.get(feedbackId);
477
itemEl?.classList.add('focused');
478
479
// Show range highlighting
480
this._highlightRange(feedback);
481
}
482
483
/**
484
* Clear focus state and range highlighting.
485
*/
486
clearFocus(): void {
487
for (const el of this._itemElements.values()) {
488
el.classList.remove('focused');
489
}
490
this._rangeHighlightDecoration.clear();
491
}
492
493
private _highlightRange(feedback: ISessionEditorComment): void {
494
const endLineNumber = feedback.range.endLineNumber;
495
const range = new Range(
496
feedback.range.startLineNumber, 1,
497
endLineNumber, this._editor.getModel()?.getLineMaxColumn(endLineNumber) ?? 1
498
);
499
this._rangeHighlightDecoration.set([
500
{
501
range,
502
options: {
503
description: 'agent-feedback-range-highlight',
504
className: 'rangeHighlight',
505
isWholeLine: true,
506
linesDecorationsClassName: 'agent-feedback-widget-range-glyph',
507
}
508
},
509
{
510
range,
511
options: {
512
description: 'agent-feedback-range-highlight-overview',
513
overviewRuler: {
514
color: themeColorFromId(overviewRulerRangeHighlight),
515
position: OverviewRulerLane.Full,
516
}
517
}
518
}
519
]);
520
}
521
522
/**
523
* Returns true if this widget contains the given feedback item (by id).
524
*/
525
containsFeedback(feedbackId: string): boolean {
526
return this._commentItems.some(f => f.id === feedbackId);
527
}
528
529
/**
530
* Updates the widget position and layout.
531
*/
532
layout(startLineNumber: number): void {
533
if (this._disposed) {
534
return;
535
}
536
537
this._startLineNumber = startLineNumber;
538
539
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
540
const { contentLeft, contentWidth, verticalScrollbarWidth } = this._editor.getLayoutInfo();
541
const scrollTop = this._editor.getScrollTop();
542
543
const widgetWidth = getTotalWidth(this._domNode) || 280;
544
const widgetHeight = this._domNode.offsetHeight || 0;
545
const headerHeight = this._headerNode.offsetHeight || lineHeight;
546
547
// Align the header center with the start line center before clamping within the editor content area.
548
const contentRelativeTop = this._editor.getTopForLineNumber(startLineNumber) + (lineHeight - headerHeight) / 2;
549
const scrollHeight = this._editor.getScrollHeight();
550
const clampedContentTop = Math.min(Math.max(0, contentRelativeTop), Math.max(0, scrollHeight - widgetHeight));
551
552
this._position = {
553
stackOrdinal: 2,
554
preference: {
555
top: clampedContentTop - scrollTop,
556
left: contentLeft + contentWidth - (2 * verticalScrollbarWidth + widgetWidth)
557
}
558
};
559
560
this._editor.layoutOverlayWidget(this);
561
}
562
563
/**
564
* Shows or hides the widget.
565
*/
566
toggle(show: boolean): void {
567
this._domNode.classList.toggle('visible', show);
568
if (show && this._commentItems.length > 0) {
569
this.layout(this._commentItems[0].range.startLineNumber);
570
}
571
}
572
573
/**
574
* Relayouts the widget at its current line number.
575
*/
576
relayout(): void {
577
if (this._startLineNumber) {
578
this.layout(this._startLineNumber);
579
}
580
}
581
582
// IOverlayWidget implementation
583
584
getId(): string {
585
return this._id;
586
}
587
588
getDomNode(): HTMLElement {
589
return this._domNode;
590
}
591
592
getPosition(): IOverlayWidgetPosition | null {
593
return this._position;
594
}
595
596
override dispose(): void {
597
if (this._disposed) {
598
return;
599
}
600
this._disposed = true;
601
this._rangeHighlightDecoration.clear();
602
this._editor.removeOverlayWidget(this);
603
super.dispose();
604
}
605
606
private _revealComment(comment: ISessionEditorComment): void {
607
const range = new Range(
608
comment.range.startLineNumber,
609
1,
610
comment.range.endLineNumber,
611
this._editor.getModel()?.getLineMaxColumn(comment.range.endLineNumber) ?? 1,
612
);
613
this._editor.revealRangeInCenterIfOutsideViewport(range, ScrollType.Smooth);
614
}
615
}
616
617
/**
618
* Editor contribution that manages agent feedback widgets.
619
* Groups feedback items and creates combined widgets for nearby items.
620
* Widgets start collapsed and expand when navigated to.
621
*/
622
class AgentFeedbackEditorWidgetContribution extends Disposable implements IEditorContribution {
623
624
static readonly ID = 'agentFeedback.editorWidgetContribution';
625
626
private readonly _widgets: AgentFeedbackEditorWidget[] = [];
627
private _sessionResource: URI | undefined;
628
629
constructor(
630
private readonly _editor: ICodeEditor,
631
@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,
632
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
633
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
634
@ICodeReviewService private readonly _codeReviewService: ICodeReviewService,
635
@IInstantiationService private readonly _instantiationService: IInstantiationService,
636
) {
637
super();
638
639
this._store.add(this._agentFeedbackService.onDidChangeNavigation(sessionResource => {
640
if (this._sessionResource && sessionResource.toString() === this._sessionResource.toString()) {
641
this._handleNavigation();
642
}
643
}));
644
645
const rebuildSignal = observableSignalFromEvent(this, Event.any(
646
this._agentFeedbackService.onDidChangeFeedback,
647
this._editor.onDidChangeModel,
648
));
649
650
this._store.add(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => {
651
for (const widget of this._widgets) {
652
widget.relayout();
653
}
654
}));
655
656
this._store.add(autorun(reader => {
657
rebuildSignal.read(reader);
658
this._resolveSession();
659
if (!this._sessionResource) {
660
this._clearWidgets();
661
return;
662
}
663
664
this._rebuildWidgets(
665
this._codeReviewService.getReviewState(this._sessionResource).read(reader),
666
this._codeReviewService.getPRReviewState(this._sessionResource).read(reader),
667
);
668
this._handleNavigation();
669
}));
670
}
671
672
private _resolveSession(): void {
673
const model = this._editor.getModel();
674
if (!model) {
675
this._sessionResource = undefined;
676
return;
677
}
678
this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._sessionsManagementService);
679
}
680
681
private _rebuildWidgets(
682
reviewState = this._sessionResource ? this._codeReviewService.getReviewState(this._sessionResource).get() : undefined,
683
prReviewState: IPRReviewState | undefined = this._sessionResource ? this._codeReviewService.getPRReviewState(this._sessionResource).get() : undefined,
684
): void {
685
this._clearWidgets();
686
687
if (!this._sessionResource || !reviewState) {
688
return;
689
}
690
691
const model = this._editor.getModel();
692
if (!model) {
693
return;
694
}
695
696
const comments = getSessionEditorComments(
697
this._sessionResource,
698
this._agentFeedbackService.getFeedback(this._sessionResource),
699
reviewState,
700
prReviewState,
701
);
702
const fileComments = this._getCommentsForModel(model.uri, comments);
703
if (fileComments.length === 0) {
704
return;
705
}
706
707
const groups = groupNearbySessionEditorComments(fileComments, 5);
708
709
// Create widgets in reverse file order so that widgets further up in the
710
// file are added to the DOM last and therefore render on top of widgets
711
// further down.
712
for (let i = groups.length - 1; i >= 0; i--) {
713
const group = groups[i];
714
const widget = this._instantiationService.createInstance(AgentFeedbackEditorWidget, this._editor, group, this._sessionResource);
715
this._widgets.push(widget);
716
717
widget.layout(group[0].range.startLineNumber);
718
}
719
}
720
721
private _getCommentsForModel(resourceUri: URI, comments: readonly ISessionEditorComment[]): readonly ISessionEditorComment[] {
722
const change = this._getSessionChangeForResource(resourceUri);
723
if (!change) {
724
return comments.filter(comment => isEqual(comment.resourceUri, resourceUri));
725
}
726
727
if (!this._isCurrentOrModifiedResource(change, resourceUri)) {
728
return [];
729
}
730
731
return comments.filter(comment => comment.resourceUri.fsPath === resourceUri.fsPath);
732
}
733
734
private _getSessionChangeForResource(resourceUri: URI): ISessionFileChange | undefined {
735
if (!this._sessionResource) {
736
return undefined;
737
}
738
739
const changes = this._sessionsManagementService.getSession(this._sessionResource)?.changes.get();
740
if (!changes) {
741
return undefined;
742
}
743
744
return changes.find(change => this._changeMatchesFsPath(change, resourceUri));
745
}
746
747
private _changeMatchesFsPath(change: ISessionFileChange, resourceUri: URI): boolean {
748
if (isIChatSessionFileChange2(change)) {
749
return change.uri.fsPath === resourceUri.fsPath
750
|| change.modifiedUri?.fsPath === resourceUri.fsPath
751
|| change.originalUri?.fsPath === resourceUri.fsPath;
752
}
753
754
return change.modifiedUri.fsPath === resourceUri.fsPath
755
|| change.originalUri?.fsPath === resourceUri.fsPath;
756
}
757
758
private _isCurrentOrModifiedResource(change: ISessionFileChange, resourceUri: URI): boolean {
759
if (isIChatSessionFileChange2(change)) {
760
return isEqual(change.uri, resourceUri) || (change.modifiedUri ? isEqual(change.modifiedUri, resourceUri) : false);
761
}
762
763
return isEqual(change.modifiedUri, resourceUri);
764
}
765
766
private _handleNavigation(): void {
767
if (!this._sessionResource) {
768
return;
769
}
770
771
const model = this._editor.getModel();
772
if (!model) {
773
return;
774
}
775
776
const comments = getSessionEditorComments(
777
this._sessionResource,
778
this._agentFeedbackService.getFeedback(this._sessionResource),
779
this._codeReviewService.getReviewState(this._sessionResource).get(),
780
this._codeReviewService.getPRReviewState(this._sessionResource).get(),
781
);
782
const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource, comments);
783
if (bearing.activeIdx < 0) {
784
return;
785
}
786
787
const activeFeedback = comments[bearing.activeIdx];
788
if (!activeFeedback) {
789
return;
790
}
791
792
if (this._getCommentsForModel(model.uri, [activeFeedback]).length === 0) {
793
for (const widget of this._widgets) {
794
widget.collapse();
795
}
796
return;
797
}
798
799
// Expand the widget containing the active feedback, collapse all others
800
for (const widget of this._widgets) {
801
if (widget.containsFeedback(activeFeedback.id)) {
802
widget.expand();
803
widget.focusFeedback(activeFeedback.id);
804
} else {
805
widget.collapse();
806
}
807
}
808
809
// Reveal the feedback range in the editor
810
const range = new Range(
811
activeFeedback.range.startLineNumber, 1,
812
activeFeedback.range.endLineNumber, 1
813
);
814
this._editor.revealRangeInCenterIfOutsideViewport(range, ScrollType.Smooth);
815
}
816
817
private _clearWidgets(): void {
818
for (const widget of this._widgets) {
819
widget.dispose();
820
}
821
this._widgets.length = 0;
822
}
823
824
override dispose(): void {
825
this._clearWidgets();
826
super.dispose();
827
}
828
}
829
830
registerEditorContribution(AgentFeedbackEditorWidgetContribution.ID, AgentFeedbackEditorWidgetContribution, EditorContributionInstantiation.Eventually);
831
832