Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationWidget.ts
5243 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/chatEditingExplanationWidget.css';
7
8
import { Codicon } from '../../../../../base/common/codicons.js';
9
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
10
import { Event } from '../../../../../base/common/event.js';
11
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../../editor/browser/editorBrowser.js';
12
import { EditorOption } from '../../../../../editor/common/config/editorOptions.js';
13
import { DetailedLineRangeMapping, LineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js';
14
import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js';
15
import { $, addDisposableListener, clearNode, getTotalWidth } from '../../../../../base/browser/dom.js';
16
import { ThemeIcon } from '../../../../../base/common/themables.js';
17
import { URI } from '../../../../../base/common/uri.js';
18
import { Range } from '../../../../../editor/common/core/range.js';
19
import { overviewRulerRangeHighlight } from '../../../../../editor/common/core/editorColorRegistry.js';
20
import { IEditorDecorationsCollection } from '../../../../../editor/common/editorCommon.js';
21
import { OverviewRulerLane } from '../../../../../editor/common/model.js';
22
import { themeColorFromId } from '../../../../../platform/theme/common/themeService.js';
23
import { ChatViewId, IChatWidget, IChatWidgetService } from '../chat.js';
24
import { IViewsService } from '../../../../services/views/common/viewsService.js';
25
import * as nls from '../../../../../nls.js';
26
import { IExplanationDiffInfo, IChangeExplanation as IChangeExplanationModel, IChatEditingExplanationModelManager } from './chatEditingExplanationModelManager.js';
27
import { autorun } from '../../../../../base/common/observable.js';
28
29
/**
30
* Explanation data for a single change hunk
31
*/
32
interface IChangeExplanation {
33
readonly startLineNumber: number;
34
readonly endLineNumber: number;
35
explanation: string;
36
read: boolean;
37
loading: boolean;
38
readonly originalText: string;
39
readonly modifiedText: string;
40
}
41
42
/**
43
* Gets the text content for a change
44
*/
45
function getChangeTexts(change: LineRangeMapping | DetailedLineRangeMapping, diffInfo: IExplanationDiffInfo): { originalText: string; modifiedText: string } {
46
const originalLines: string[] = [];
47
const modifiedLines: string[] = [];
48
49
// Get original text
50
for (let i = change.original.startLineNumber; i < change.original.endLineNumberExclusive; i++) {
51
const line = diffInfo.originalModel.getLineContent(i);
52
originalLines.push(line);
53
}
54
55
// Get modified text
56
for (let i = change.modified.startLineNumber; i < change.modified.endLineNumberExclusive; i++) {
57
const line = diffInfo.modifiedModel.getLineContent(i);
58
modifiedLines.push(line);
59
}
60
61
return {
62
originalText: originalLines.join('\n'),
63
modifiedText: modifiedLines.join('\n')
64
};
65
}
66
67
/**
68
* Groups nearby changes within a threshold number of lines
69
* Uses the vertical span from widget position to last line it refers to
70
*/
71
function groupNearbyChanges<T extends LineRangeMapping>(changes: readonly T[], lineThreshold: number = 5): T[][] {
72
if (changes.length === 0) {
73
return [];
74
}
75
76
const groups: T[][] = [];
77
let currentGroup: T[] = [changes[0]];
78
79
for (let i = 1; i < changes.length; i++) {
80
const firstChange = currentGroup[0];
81
const currentChange = changes[i];
82
83
// Calculate vertical span from widget position (first change) to start of current change
84
const widgetLine = firstChange.modified.startLineNumber;
85
const lastLine = currentChange.modified.startLineNumber;
86
const verticalSpan = lastLine - widgetLine;
87
88
if (verticalSpan <= lineThreshold) {
89
currentGroup.push(currentChange);
90
} else {
91
groups.push(currentGroup);
92
currentGroup = [currentChange];
93
}
94
}
95
96
if (currentGroup.length > 0) {
97
groups.push(currentGroup);
98
}
99
100
return groups;
101
}
102
103
/**
104
* Widget that displays explanatory comments for chat-made changes
105
* Positioned on the right side of the editor like a speech bubble
106
*/
107
export class ChatEditingExplanationWidget extends Disposable implements IOverlayWidget {
108
109
private static _idPool = 0;
110
private readonly _id: string = `chat-explanation-widget-${ChatEditingExplanationWidget._idPool++}`;
111
112
private readonly _domNode: HTMLElement;
113
private readonly _headerNode: HTMLElement;
114
private readonly _readIndicator: HTMLElement;
115
private readonly _titleNode: HTMLElement;
116
private readonly _dismissButton: HTMLElement;
117
private readonly _toggleButton: HTMLElement;
118
private readonly _bodyNode: HTMLElement;
119
private readonly _explanationItems: Map<number, { item: HTMLElement; readIndicator: HTMLElement; textElement: HTMLElement }> = new Map();
120
121
private _position: IOverlayWidgetPosition | null = null;
122
private _explanations: IChangeExplanation[] = [];
123
private _isExpanded: boolean = true;
124
private _isAllRead: boolean = false;
125
private _disposed: boolean = false;
126
private _startLineNumber: number = 1;
127
private readonly _uri: URI;
128
private readonly _rangeHighlightDecoration: IEditorDecorationsCollection;
129
130
private readonly _eventStore = this._register(new DisposableStore());
131
132
constructor(
133
private readonly _editor: ICodeEditor,
134
private _changes: readonly (LineRangeMapping | DetailedLineRangeMapping)[],
135
diffInfo: IExplanationDiffInfo,
136
private readonly _chatWidgetService: IChatWidgetService,
137
private readonly _viewsService: IViewsService,
138
private readonly _chatSessionResource?: URI,
139
) {
140
super();
141
142
this._uri = diffInfo.modifiedModel.uri;
143
144
// Create decoration collection for range highlighting on hover
145
this._rangeHighlightDecoration = this._editor.createDecorationsCollection();
146
147
// Build explanations from changes with loading state
148
this._explanations = this._changes.map(change => {
149
const { originalText, modifiedText } = getChangeTexts(change, diffInfo);
150
return {
151
startLineNumber: change.modified.startLineNumber,
152
endLineNumber: change.modified.endLineNumberExclusive - 1,
153
explanation: nls.localize('generatingExplanation', "Generating explanation..."),
154
read: false,
155
loading: true,
156
originalText,
157
modifiedText,
158
};
159
});
160
161
// Create DOM structure
162
this._domNode = $('div.chat-explanation-widget');
163
164
// Header
165
this._headerNode = $('div.chat-explanation-header');
166
167
// Read indicator (checkbox-like)
168
this._readIndicator = $('div.chat-explanation-read-indicator');
169
this._updateReadIndicator();
170
this._headerNode.appendChild(this._readIndicator);
171
172
// Title showing change count
173
this._titleNode = $('span.chat-explanation-title');
174
this._updateTitle();
175
this._headerNode.appendChild(this._titleNode);
176
177
// Spacer
178
this._headerNode.appendChild($('span.chat-explanation-spacer'));
179
180
// Toggle expand/collapse button
181
this._toggleButton = $('div.chat-explanation-toggle');
182
this._updateToggleButton();
183
this._headerNode.appendChild(this._toggleButton);
184
185
// Dismiss button
186
this._dismissButton = $('div.chat-explanation-dismiss');
187
this._dismissButton.appendChild(renderIcon(Codicon.close));
188
this._dismissButton.title = nls.localize('dismiss', "Dismiss");
189
this._headerNode.appendChild(this._dismissButton);
190
191
this._domNode.appendChild(this._headerNode);
192
193
// Body (collapsible)
194
this._bodyNode = $('div.chat-explanation-body');
195
// Body starts expanded by default
196
this._buildExplanationItems();
197
this._domNode.appendChild(this._bodyNode);
198
199
// Arrow pointer
200
const arrow = $('div.chat-explanation-arrow');
201
this._domNode.appendChild(arrow);
202
203
// Event handlers
204
this._setupEventHandlers();
205
206
// Add visible class for initial display
207
this._domNode.classList.add('visible');
208
209
// Add to editor
210
this._editor.addOverlayWidget(this);
211
}
212
213
private _setupEventHandlers(): void {
214
// Read indicator click - toggle all read/unread
215
this._eventStore.add(addDisposableListener(this._readIndicator, 'click', (e) => {
216
e.stopPropagation();
217
this._isAllRead = !this._isAllRead;
218
for (const exp of this._explanations) {
219
exp.read = this._isAllRead;
220
}
221
this._updateReadIndicator();
222
this._updateExplanationItemsReadState();
223
}));
224
225
// Toggle button click - expand/collapse
226
this._eventStore.add(addDisposableListener(this._toggleButton, 'click', (e) => {
227
e.stopPropagation();
228
this._toggleExpanded();
229
}));
230
231
// Header click - also toggles expand/collapse
232
this._eventStore.add(addDisposableListener(this._headerNode, 'click', () => {
233
this._toggleExpanded();
234
}));
235
236
// Dismiss button click
237
this._eventStore.add(addDisposableListener(this._dismissButton, 'click', (e) => {
238
e.stopPropagation();
239
this._dismiss();
240
}));
241
}
242
243
private _toggleExpanded(): void {
244
this._isExpanded = !this._isExpanded;
245
this._bodyNode.classList.toggle('collapsed', !this._isExpanded);
246
this._updateToggleButton();
247
this._editor.layoutOverlayWidget(this);
248
}
249
250
private _dismiss(): void {
251
this._domNode.classList.add('fadeOut');
252
253
const dispose = () => {
254
this.dispose();
255
};
256
257
// Listen for animation end
258
const handle = setTimeout(dispose, 150);
259
this._domNode.addEventListener('animationend', () => {
260
clearTimeout(handle);
261
dispose();
262
}, { once: true });
263
}
264
265
private _updateReadIndicator(): void {
266
clearNode(this._readIndicator);
267
const allRead = this._explanations.every(e => e.read);
268
const someRead = this._explanations.some(e => e.read);
269
this._isAllRead = allRead;
270
271
if (allRead) {
272
this._readIndicator.appendChild(renderIcon(Codicon.circle));
273
this._readIndicator.classList.add('read');
274
this._readIndicator.classList.remove('partial', 'unread');
275
this._readIndicator.title = nls.localize('markAsUnread', "Mark as unread");
276
} else if (someRead) {
277
this._readIndicator.appendChild(renderIcon(Codicon.circleFilled));
278
this._readIndicator.classList.remove('read', 'unread');
279
this._readIndicator.classList.add('partial');
280
this._readIndicator.title = nls.localize('markAllAsRead', "Mark all as read");
281
} else {
282
this._readIndicator.appendChild(renderIcon(Codicon.circleFilled));
283
this._readIndicator.classList.remove('read', 'partial');
284
this._readIndicator.classList.add('unread');
285
this._readIndicator.title = nls.localize('markAsRead', "Mark as read");
286
}
287
}
288
289
private _updateTitle(): void {
290
const count = this._explanations.length;
291
if (count === 1) {
292
this._titleNode.textContent = nls.localize('oneChange', "1 change");
293
} else {
294
this._titleNode.textContent = nls.localize('nChanges', "{0} changes", count);
295
}
296
}
297
298
private _updateToggleButton(): void {
299
clearNode(this._toggleButton);
300
if (this._isExpanded) {
301
this._toggleButton.appendChild(renderIcon(Codicon.chevronUp));
302
this._toggleButton.title = nls.localize('collapse', "Collapse");
303
} else {
304
this._toggleButton.appendChild(renderIcon(Codicon.chevronDown));
305
this._toggleButton.title = nls.localize('expand', "Expand");
306
}
307
}
308
309
private _buildExplanationItems(): void {
310
clearNode(this._bodyNode);
311
this._explanationItems.clear();
312
313
for (let i = 0; i < this._explanations.length; i++) {
314
const exp = this._explanations[i];
315
const item = $('div.chat-explanation-item');
316
317
// Line indicator
318
const lineInfo = $('span.chat-explanation-line-info');
319
if (exp.startLineNumber === exp.endLineNumber) {
320
lineInfo.textContent = nls.localize('lineNumber', "Line {0}", exp.startLineNumber);
321
} else {
322
lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", exp.startLineNumber, exp.endLineNumber);
323
}
324
item.appendChild(lineInfo);
325
326
// Explanation text with loading indicator
327
const text = $('span.chat-explanation-text');
328
if (exp.loading) {
329
const loadingIcon = renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'));
330
loadingIcon.classList.add('chat-explanation-loading');
331
text.appendChild(loadingIcon);
332
const loadingText = document.createTextNode(' ' + exp.explanation);
333
text.appendChild(loadingText);
334
} else {
335
text.textContent = exp.explanation;
336
}
337
item.appendChild(text);
338
339
// Item read indicator
340
const itemReadIndicator = $('div.chat-explanation-item-read');
341
this._updateItemReadIndicator(itemReadIndicator, exp.read);
342
item.appendChild(itemReadIndicator);
343
344
// Reply button to add context to chat
345
const replyButton = $('div.chat-explanation-reply-button');
346
replyButton.appendChild(renderIcon(Codicon.arrowRight));
347
replyButton.title = nls.localize('followUpOnChange', "Follow up on this change");
348
item.appendChild(replyButton);
349
350
// Reply button click handler
351
this._eventStore.add(addDisposableListener(replyButton, 'click', async (e) => {
352
e.stopPropagation();
353
const range = new Range(exp.startLineNumber, 1, exp.endLineNumber, 1);
354
let chatWidget: IChatWidget | undefined;
355
if (this._chatSessionResource) {
356
chatWidget = await this._chatWidgetService.openSession(this._chatSessionResource);
357
} else {
358
await this._viewsService.openView(ChatViewId, true);
359
chatWidget = this._chatWidgetService.lastFocusedWidget;
360
}
361
if (chatWidget) {
362
chatWidget.attachmentModel.addContext(
363
chatWidget.attachmentModel.asFileVariableEntry(this._uri, range)
364
);
365
}
366
}));
367
368
// Click on item to mark as read
369
this._eventStore.add(addDisposableListener(item, 'click', (e) => {
370
e.stopPropagation();
371
exp.read = !exp.read;
372
this._updateItemReadIndicator(itemReadIndicator, exp.read);
373
this._updateReadIndicator();
374
}));
375
376
// Hover handlers for range highlighting
377
this._eventStore.add(addDisposableListener(item, 'mouseenter', () => {
378
const range = new Range(exp.startLineNumber, 1, exp.endLineNumber, this._editor.getModel()?.getLineMaxColumn(exp.endLineNumber) ?? 1);
379
this._rangeHighlightDecoration.set([
380
// Line highlight with gutter decoration
381
{
382
range,
383
options: {
384
description: 'chat-explanation-range-highlight',
385
className: 'rangeHighlight',
386
isWholeLine: true,
387
linesDecorationsClassName: 'chat-explanation-range-glyph',
388
}
389
},
390
// Overview ruler indicator
391
{
392
range,
393
options: {
394
description: 'chat-explanation-range-highlight-overview',
395
overviewRuler: {
396
color: themeColorFromId(overviewRulerRangeHighlight),
397
position: OverviewRulerLane.Full,
398
}
399
}
400
}
401
]);
402
}));
403
404
this._eventStore.add(addDisposableListener(item, 'mouseleave', () => {
405
this._rangeHighlightDecoration.clear();
406
}));
407
408
this._explanationItems.set(i, { item, readIndicator: itemReadIndicator, textElement: text });
409
this._bodyNode.appendChild(item);
410
}
411
}
412
413
/**
414
* Sets the explanation for a change matching the given line number range.
415
* @returns true if a matching explanation was found and updated
416
*/
417
setExplanationByLineNumber(startLineNumber: number, endLineNumber: number, explanation: string): boolean {
418
for (let i = 0; i < this._explanations.length; i++) {
419
const exp = this._explanations[i];
420
if (exp.startLineNumber === startLineNumber && exp.endLineNumber === endLineNumber) {
421
exp.explanation = explanation;
422
exp.loading = false;
423
this._updateExplanationText(i);
424
return true;
425
}
426
}
427
return false;
428
}
429
430
/**
431
* Gets the number of explanations in this widget.
432
*/
433
get explanationCount(): number {
434
return this._explanations.length;
435
}
436
437
private _updateExplanationText(index: number): void {
438
const itemData = this._explanationItems.get(index);
439
const exp = this._explanations[index];
440
if (itemData && exp) {
441
clearNode(itemData.textElement);
442
itemData.textElement.textContent = exp.explanation;
443
}
444
}
445
446
private _updateItemReadIndicator(element: HTMLElement, read: boolean): void {
447
clearNode(element);
448
if (read) {
449
element.appendChild(renderIcon(Codicon.circle));
450
element.classList.add('read');
451
element.classList.remove('unread');
452
} else {
453
element.appendChild(renderIcon(Codicon.circleFilled));
454
element.classList.remove('read');
455
element.classList.add('unread');
456
}
457
}
458
459
private _updateExplanationItemsReadState(): void {
460
this._explanationItems.forEach(({ readIndicator }, index) => {
461
const exp = this._explanations[index];
462
this._updateItemReadIndicator(readIndicator, exp.read);
463
});
464
}
465
466
/**
467
* Updates the widget position and layout
468
*/
469
layout(startLineNumber: number): void {
470
if (this._disposed) {
471
return;
472
}
473
474
this._startLineNumber = startLineNumber;
475
476
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
477
const { contentLeft, contentWidth, verticalScrollbarWidth } = this._editor.getLayoutInfo();
478
const scrollTop = this._editor.getScrollTop();
479
480
// Position at right edge like DiffHunkWidget
481
const widgetWidth = getTotalWidth(this._domNode) || 280;
482
483
this._position = {
484
stackOrdinal: 2,
485
preference: {
486
top: this._editor.getTopForLineNumber(startLineNumber) - scrollTop - lineHeight,
487
left: contentLeft + contentWidth - (2 * verticalScrollbarWidth + widgetWidth)
488
}
489
};
490
491
this._editor.layoutOverlayWidget(this);
492
}
493
494
/**
495
* Shows or hides the widget
496
*/
497
toggle(show: boolean): void {
498
this._domNode.classList.toggle('visible', show);
499
if (show && this._explanations.length > 0) {
500
this.layout(this._explanations[0].startLineNumber);
501
}
502
}
503
504
/**
505
* Relayouts the widget at its current line number
506
*/
507
relayout(): void {
508
if (this._startLineNumber) {
509
this.layout(this._startLineNumber);
510
}
511
}
512
513
// IOverlayWidget implementation
514
515
getId(): string {
516
return this._id;
517
}
518
519
getDomNode(): HTMLElement {
520
return this._domNode;
521
}
522
523
getPosition(): IOverlayWidgetPosition | null {
524
return this._position;
525
}
526
527
override dispose(): void {
528
if (this._disposed) {
529
return;
530
}
531
this._disposed = true;
532
this._rangeHighlightDecoration.clear();
533
this._editor.removeOverlayWidget(this);
534
super.dispose();
535
}
536
}
537
538
/**
539
* Manager for explanation widgets in an editor
540
* Groups changes and creates combined widgets for nearby changes
541
*/
542
export class ChatEditingExplanationWidgetManager extends Disposable {
543
544
private readonly _widgets: ChatEditingExplanationWidget[] = [];
545
private _visible: boolean = false;
546
547
private _chatSessionResource: URI | undefined;
548
private _diffInfo: IExplanationDiffInfo | undefined;
549
550
constructor(
551
private readonly _editor: ICodeEditor,
552
private readonly _chatWidgetService: IChatWidgetService,
553
private readonly _viewsService: IViewsService,
554
modelManager: IChatEditingExplanationModelManager,
555
private readonly _modelUri: URI,
556
) {
557
super();
558
559
// Listen for model changes - hide/show widgets based on whether current model matches
560
this._register(this._editor.onDidChangeModel(() => {
561
const newUri = this._editor.getModel()?.uri;
562
if (this._modelUri) {
563
if (newUri && newUri.toString() === this._modelUri.toString()) {
564
// Switched back to the file - show widgets
565
for (const widget of this._widgets) {
566
widget.toggle(this._visible);
567
widget.relayout();
568
}
569
} else {
570
// Switched to a different file - hide widgets
571
for (const widget of this._widgets) {
572
widget.toggle(false);
573
}
574
}
575
}
576
}));
577
578
// Observe state from model manager
579
this._register(autorun(r => {
580
const state = modelManager.state.read(r);
581
const uriState = state.get(this._modelUri);
582
583
if (uriState) {
584
// Update diffInfo and chatSessionResource from state
585
this._diffInfo = uriState.diffInfo;
586
this._chatSessionResource = uriState.chatSessionResource;
587
588
// Ensure widgets are created
589
if (this._widgets.length === 0 && this._diffInfo) {
590
this._createWidgets(this._diffInfo, this._chatSessionResource);
591
}
592
// Handle explanation state changes
593
if (uriState.progress === 'complete') {
594
this._handleExplanations(this._modelUri, uriState.explanations);
595
}
596
this.show();
597
} else {
598
this.hide();
599
}
600
}));
601
}
602
603
private _createWidgets(diffInfo: IExplanationDiffInfo, chatSessionResource: URI | undefined): void {
604
if (diffInfo.identical || diffInfo.changes.length === 0) {
605
return;
606
}
607
608
// Group nearby changes
609
const groups = groupNearbyChanges(diffInfo.changes, 5);
610
611
// Create a widget for each group
612
for (const group of groups) {
613
const widget = new ChatEditingExplanationWidget(
614
this._editor,
615
group,
616
diffInfo,
617
this._chatWidgetService,
618
this._viewsService,
619
chatSessionResource,
620
);
621
this._widgets.push(widget);
622
this._register(widget);
623
624
// Layout at the first change in the group
625
widget.layout(group[0].modified.startLineNumber);
626
}
627
628
// Relayout on scroll/layout changes
629
this._register(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => {
630
for (const widget of this._widgets) {
631
widget.relayout();
632
}
633
}));
634
}
635
636
private _handleExplanations(uri: URI, explanations: readonly IChangeExplanationModel[]): void {
637
if (!this._modelUri || uri.toString() !== this._modelUri.toString()) {
638
return;
639
}
640
641
// Map explanations to widgets by matching line numbers
642
for (const explanation of explanations) {
643
for (const widget of this._widgets) {
644
// Try to set the explanation on the widget - it will match by line number
645
if (widget.setExplanationByLineNumber(
646
explanation.startLineNumber,
647
explanation.endLineNumber,
648
explanation.explanation
649
)) {
650
break; // Found the matching widget, no need to check others
651
}
652
}
653
}
654
}
655
656
/**
657
* Shows all widgets
658
*/
659
show(): void {
660
this._visible = true;
661
for (const widget of this._widgets) {
662
widget.toggle(true);
663
widget.relayout();
664
}
665
}
666
667
/**
668
* Hides all widgets
669
*/
670
hide(): void {
671
this._visible = false;
672
for (const widget of this._widgets) {
673
widget.toggle(false);
674
}
675
}
676
677
private _clearWidgets(): void {
678
for (const widget of this._widgets) {
679
widget.dispose();
680
}
681
this._widgets.length = 0;
682
}
683
684
override dispose(): void {
685
this._clearWidgets();
686
super.dispose();
687
}
688
}
689
690