Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.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 * as dom from '../../../../base/browser/dom.js';
7
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
8
import { HoverStyle, IDelayedHoverOptions } from '../../../../base/browser/ui/hover/hover.js';
9
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
10
import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
11
import { IObjectTreeElement, ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js';
12
import { Action } from '../../../../base/common/actions.js';
13
import { Codicon } from '../../../../base/common/codicons.js';
14
import { MarkdownString } from '../../../../base/common/htmlContent.js';
15
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
16
import { basename } from '../../../../base/common/path.js';
17
import { ThemeIcon } from '../../../../base/common/themables.js';
18
import { URI } from '../../../../base/common/uri.js';
19
import { ILanguageService } from '../../../../editor/common/languages/language.js';
20
import { localize } from '../../../../nls.js';
21
import { FileKind } from '../../../../platform/files/common/files.js';
22
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
23
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
24
import { WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js';
25
import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';
26
import { IAgentFeedbackService } from './agentFeedbackService.js';
27
import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
28
import { editorHoverBackground } from '../../../../platform/theme/common/colorRegistry.js';
29
30
const $ = dom.$;
31
32
// --- Tree Element Types ---
33
34
interface IFeedbackFileElement {
35
readonly type: 'file';
36
readonly uri: URI;
37
readonly items: ReadonlyArray<IFeedbackCommentElement>;
38
}
39
40
interface IFeedbackCommentElement {
41
readonly type: 'comment';
42
readonly id: string;
43
readonly text: string;
44
readonly resourceUri: URI;
45
readonly codeSelection?: string;
46
readonly diffHunks?: string;
47
}
48
49
type FeedbackTreeElement = IFeedbackFileElement | IFeedbackCommentElement;
50
51
function isFeedbackFileElement(element: FeedbackTreeElement): element is IFeedbackFileElement {
52
return element.type === 'file';
53
}
54
55
// --- Tree Delegate ---
56
57
class FeedbackTreeDelegate implements IListVirtualDelegate<FeedbackTreeElement> {
58
getHeight(_element: FeedbackTreeElement): number {
59
return 22;
60
}
61
62
getTemplateId(element: FeedbackTreeElement): string {
63
return isFeedbackFileElement(element)
64
? FeedbackFileRenderer.TEMPLATE_ID
65
: FeedbackCommentRenderer.TEMPLATE_ID;
66
}
67
}
68
69
// --- File Renderer ---
70
71
interface IFeedbackFileTemplate {
72
readonly label: IResourceLabel;
73
readonly actionBar: ActionBar;
74
readonly templateDisposables: DisposableStore;
75
}
76
77
class FeedbackFileRenderer implements ITreeRenderer<IFeedbackFileElement, void, IFeedbackFileTemplate> {
78
static readonly TEMPLATE_ID = 'feedbackFile';
79
readonly templateId = FeedbackFileRenderer.TEMPLATE_ID;
80
81
constructor(
82
private readonly _labels: ResourceLabels,
83
private readonly _agentFeedbackService: IAgentFeedbackService | undefined,
84
private readonly _sessionResource: URI,
85
) { }
86
87
renderTemplate(container: HTMLElement): IFeedbackFileTemplate {
88
const templateDisposables = new DisposableStore();
89
90
const label = templateDisposables.add(this._labels.create(container, { supportHighlights: true, supportIcons: true }));
91
92
const actionBarContainer = $('div.agent-feedback-hover-action-bar');
93
label.element.appendChild(actionBarContainer);
94
const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));
95
96
return { label, actionBar, templateDisposables };
97
}
98
99
renderElement(node: ITreeNode<IFeedbackFileElement, void>, _index: number, templateData: IFeedbackFileTemplate): void {
100
const element = node.element;
101
templateData.label.element.style.display = 'flex';
102
103
const name = basename(element.uri.path);
104
105
106
templateData.label.setResource(
107
{ resource: element.uri, name },
108
{ fileKind: FileKind.FILE },
109
);
110
111
templateData.actionBar.clear();
112
if (this._agentFeedbackService) {
113
const service = this._agentFeedbackService;
114
const sessionResource = this._sessionResource;
115
templateData.actionBar.push(new Action(
116
'agentFeedback.removeFileComments',
117
localize('agentFeedbackHover.removeAll', "Remove All"),
118
ThemeIcon.asClassName(Codicon.close),
119
true,
120
() => {
121
for (const item of element.items) {
122
service.removeFeedback(sessionResource, item.id);
123
}
124
}
125
), { icon: true, label: false });
126
}
127
}
128
129
disposeTemplate(templateData: IFeedbackFileTemplate): void {
130
templateData.templateDisposables.dispose();
131
}
132
}
133
134
// --- Comment Renderer ---
135
136
interface IFeedbackCommentTemplate {
137
readonly textElement: HTMLElement;
138
readonly row: HTMLElement;
139
readonly actionBar: ActionBar;
140
readonly templateDisposables: DisposableStore;
141
readonly hoverDisposable: MutableDisposable<IDisposable>;
142
element: IFeedbackCommentElement | undefined;
143
}
144
145
class FeedbackCommentRenderer implements ITreeRenderer<IFeedbackCommentElement, void, IFeedbackCommentTemplate> {
146
static readonly TEMPLATE_ID = 'feedbackComment';
147
readonly templateId = FeedbackCommentRenderer.TEMPLATE_ID;
148
149
constructor(
150
private readonly _agentFeedbackService: IAgentFeedbackService | undefined,
151
private readonly _sessionResource: URI,
152
private readonly _hoverService: IHoverService,
153
private readonly _languageService: ILanguageService,
154
) { }
155
156
renderTemplate(container: HTMLElement): IFeedbackCommentTemplate {
157
const templateDisposables = new DisposableStore();
158
159
const row = dom.append(container, $('div.agent-feedback-hover-comment-row'));
160
161
const textElement = dom.append(row, $('div.agent-feedback-hover-comment-text'));
162
163
const actionBarContainer = dom.append(row, $('div.agent-feedback-hover-action-bar'));
164
const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));
165
166
const hoverDisposable = templateDisposables.add(new MutableDisposable());
167
168
const templateData: IFeedbackCommentTemplate = { textElement, row, actionBar, templateDisposables, hoverDisposable, element: undefined };
169
170
if (this._agentFeedbackService) {
171
const service = this._agentFeedbackService;
172
const sessionResource = this._sessionResource;
173
templateDisposables.add(dom.addDisposableListener(row, dom.EventType.CLICK, (e) => {
174
const data = templateData.element;
175
if (data) {
176
e.preventDefault();
177
e.stopPropagation();
178
service.revealFeedback(sessionResource, data.id);
179
}
180
}));
181
}
182
183
return templateData;
184
}
185
186
renderElement(node: ITreeNode<IFeedbackCommentElement, void>, _index: number, templateData: IFeedbackCommentTemplate): void {
187
const element = node.element;
188
189
templateData.textElement.textContent = element.text;
190
templateData.element = element;
191
192
// In read-only mode, set up a rich markdown hover with comment + code snippet
193
if (!this._agentFeedbackService) {
194
templateData.hoverDisposable.value = this._hoverService.setupDelayedHover(
195
templateData.row,
196
() => this._buildCommentHover(element),
197
{ groupId: 'agent-feedback-comment' }
198
);
199
}
200
201
templateData.actionBar.clear();
202
if (this._agentFeedbackService) {
203
const service = this._agentFeedbackService;
204
const sessionResource = this._sessionResource;
205
templateData.actionBar.push(new Action(
206
'agentFeedback.removeComment',
207
localize('agentFeedbackHover.remove', "Remove"),
208
ThemeIcon.asClassName(Codicon.close),
209
true,
210
() => {
211
service.removeFeedback(sessionResource, element.id);
212
}
213
), { icon: true, label: false });
214
}
215
}
216
217
disposeTemplate(templateData: IFeedbackCommentTemplate): void {
218
templateData.templateDisposables.dispose();
219
}
220
221
private _buildCommentHover(element: IFeedbackCommentElement): IDelayedHoverOptions {
222
const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });
223
markdown.appendText(element.text);
224
225
if (element.codeSelection) {
226
const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(element.resourceUri);
227
markdown.appendMarkdown('\n\n');
228
markdown.appendCodeblock(languageId ?? '', element.codeSelection);
229
}
230
231
if (element.diffHunks) {
232
markdown.appendMarkdown('\n\n');
233
markdown.appendCodeblock('diff', element.diffHunks);
234
}
235
236
return {
237
content: markdown,
238
style: HoverStyle.Pointer,
239
position: {
240
hoverPosition: HoverPosition.RIGHT,
241
},
242
};
243
}
244
}
245
246
// --- Hover ---
247
248
/**
249
* Creates the custom hover content for the "N comments" attachment.
250
* Uses a WorkbenchObjectTree to render files as parent nodes and comments as children,
251
* with per-row action bars for removal.
252
*/
253
export class AgentFeedbackHover extends Disposable {
254
255
constructor(
256
private readonly _element: HTMLElement,
257
private readonly _attachment: IAgentFeedbackVariableEntry,
258
private readonly _canDelete: boolean,
259
@IHoverService private readonly _hoverService: IHoverService,
260
@IInstantiationService private readonly _instantiationService: IInstantiationService,
261
@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,
262
@ILanguageService private readonly _languageService: ILanguageService,
263
) {
264
super();
265
266
// Show on hover (delayed)
267
this._store.add(this._hoverService.setupDelayedHover(
268
this._element,
269
() => this._store.add(this._buildHoverContent()),
270
{ groupId: 'chat-attachments' }
271
));
272
273
// Show immediately on click
274
this._store.add(dom.addDisposableListener(this._element, dom.EventType.CLICK, (e) => {
275
e.preventDefault();
276
e.stopPropagation();
277
this._showHoverNow();
278
}));
279
}
280
281
private _showHoverNow(): void {
282
const opts = this._buildHoverContent();
283
this._register(opts);
284
this._hoverService.showInstantHover({
285
...opts,
286
target: this._element,
287
});
288
}
289
290
private _buildHoverContent(): IDelayedHoverOptions & IDisposable {
291
const disposables = new DisposableStore();
292
const hoverElement = $('div.agent-feedback-hover');
293
294
// Tree container
295
const treeContainer = dom.append(hoverElement, $('.results.show-file-icons.file-icon-themable-tree.agent-feedback-hover-tree'));
296
297
// Resource labels (shared across all file renderers)
298
const resourceLabels = disposables.add(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));
299
300
// Build tree data
301
const { children, commentElements } = this._buildTreeData();
302
303
// Create tree
304
const tree = disposables.add(this._instantiationService.createInstance(
305
WorkbenchObjectTree<FeedbackTreeElement>,
306
'AgentFeedbackHoverTree',
307
treeContainer,
308
new FeedbackTreeDelegate(),
309
[
310
new FeedbackFileRenderer(resourceLabels, this._canDelete ? this._agentFeedbackService : undefined, this._attachment.sessionResource),
311
new FeedbackCommentRenderer(this._canDelete ? this._agentFeedbackService : undefined, this._attachment.sessionResource, this._hoverService, this._languageService),
312
],
313
{
314
defaultIndent: 0,
315
alwaysConsumeMouseWheel: false,
316
accessibilityProvider: {
317
getAriaLabel: (element: FeedbackTreeElement) => {
318
if (isFeedbackFileElement(element)) {
319
return basename(element.uri.path);
320
}
321
return element.text;
322
},
323
getWidgetAriaLabel: () => localize('agentFeedbackHover.tree', "Feedback Comments"),
324
},
325
identityProvider: {
326
getId: (element: FeedbackTreeElement) => {
327
if (isFeedbackFileElement(element)) {
328
return `file:${element.uri.toString()}`;
329
}
330
return `comment:${element.id}`;
331
}
332
},
333
overrideStyles: {
334
listFocusBackground: undefined,
335
listInactiveFocusBackground: undefined,
336
listActiveSelectionBackground: undefined,
337
listFocusAndSelectionBackground: undefined,
338
listInactiveSelectionBackground: undefined,
339
listBackground: editorHoverBackground,
340
listFocusForeground: undefined,
341
treeStickyScrollBackground: editorHoverBackground,
342
}
343
}
344
));
345
346
// Set tree data
347
tree.setChildren(null, children);
348
349
// Layout tree: clamp to reasonable height
350
const ROW_HEIGHT = 22;
351
const MAX_ROWS = 8;
352
const totalRows = commentElements.length + children.length;
353
const treeHeight = Math.min(totalRows * ROW_HEIGHT, MAX_ROWS * ROW_HEIGHT);
354
tree.layout(treeHeight, 200);
355
treeContainer.style.height = `${treeHeight}px`;
356
357
return {
358
content: hoverElement,
359
style: HoverStyle.Pointer,
360
persistence: { hideOnHover: false },
361
position: { hoverPosition: HoverPosition.ABOVE },
362
trapFocus: true,
363
appearance: { compact: true },
364
additionalClasses: ['agent-feedback-hover-container'],
365
dispose: () => disposables.dispose(),
366
};
367
}
368
369
private _buildTreeData(): { children: IObjectTreeElement<FeedbackTreeElement>[]; commentElements: IFeedbackCommentElement[] } {
370
// Group feedback items by file
371
const byFile = new Map<string, { uri: URI; comments: IFeedbackCommentElement[] }>();
372
373
for (const item of this._attachment.feedbackItems) {
374
const key = item.resourceUri.toString();
375
let group = byFile.get(key);
376
if (!group) {
377
group = { uri: item.resourceUri, comments: [] };
378
byFile.set(key, group);
379
}
380
group.comments.push({
381
type: 'comment',
382
id: item.id,
383
text: item.text,
384
resourceUri: item.resourceUri,
385
codeSelection: item.codeSelection,
386
diffHunks: item.diffHunks,
387
});
388
}
389
390
const children: IObjectTreeElement<FeedbackTreeElement>[] = [];
391
const allComments: IFeedbackCommentElement[] = [];
392
393
for (const [, group] of byFile) {
394
const fileElement: IFeedbackFileElement = {
395
type: 'file',
396
uri: group.uri,
397
items: group.comments,
398
};
399
400
allComments.push(...group.comments);
401
402
children.push({
403
element: fileElement,
404
collapsible: true,
405
collapsed: false,
406
children: group.comments.map(comment => ({
407
element: comment,
408
collapsible: false,
409
})),
410
});
411
}
412
413
return { children, commentElements: allComments };
414
}
415
}
416
417