Path: blob/main/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';7import { HoverStyle, IDelayedHoverOptions } from '../../../../base/browser/ui/hover/hover.js';8import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';9import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';10import { IObjectTreeElement, ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js';11import { Action } from '../../../../base/common/actions.js';12import { Codicon } from '../../../../base/common/codicons.js';13import { MarkdownString } from '../../../../base/common/htmlContent.js';14import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';15import { basename } from '../../../../base/common/path.js';16import { ThemeIcon } from '../../../../base/common/themables.js';17import { URI } from '../../../../base/common/uri.js';18import { ILanguageService } from '../../../../editor/common/languages/language.js';19import { localize } from '../../../../nls.js';20import { FileKind } from '../../../../platform/files/common/files.js';21import { IHoverService } from '../../../../platform/hover/browser/hover.js';22import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';23import { WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js';24import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';25import { IAgentFeedbackService } from './agentFeedbackService.js';26import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';27import { editorHoverBackground } from '../../../../platform/theme/common/colorRegistry.js';2829const $ = dom.$;3031// --- Tree Element Types ---3233interface IFeedbackFileElement {34readonly type: 'file';35readonly uri: URI;36readonly items: ReadonlyArray<IFeedbackCommentElement>;37}3839interface IFeedbackCommentElement {40readonly type: 'comment';41readonly id: string;42readonly text: string;43readonly resourceUri: URI;44readonly codeSelection?: string;45readonly diffHunks?: string;46}4748type FeedbackTreeElement = IFeedbackFileElement | IFeedbackCommentElement;4950function isFeedbackFileElement(element: FeedbackTreeElement): element is IFeedbackFileElement {51return element.type === 'file';52}5354// --- Tree Delegate ---5556class FeedbackTreeDelegate implements IListVirtualDelegate<FeedbackTreeElement> {57getHeight(_element: FeedbackTreeElement): number {58return 22;59}6061getTemplateId(element: FeedbackTreeElement): string {62return isFeedbackFileElement(element)63? FeedbackFileRenderer.TEMPLATE_ID64: FeedbackCommentRenderer.TEMPLATE_ID;65}66}6768// --- File Renderer ---6970interface IFeedbackFileTemplate {71readonly label: IResourceLabel;72readonly actionBar: ActionBar;73readonly templateDisposables: DisposableStore;74}7576class FeedbackFileRenderer implements ITreeRenderer<IFeedbackFileElement, void, IFeedbackFileTemplate> {77static readonly TEMPLATE_ID = 'feedbackFile';78readonly templateId = FeedbackFileRenderer.TEMPLATE_ID;7980constructor(81private readonly _labels: ResourceLabels,82private readonly _agentFeedbackService: IAgentFeedbackService | undefined,83private readonly _sessionResource: URI,84) { }8586renderTemplate(container: HTMLElement): IFeedbackFileTemplate {87const templateDisposables = new DisposableStore();8889const label = templateDisposables.add(this._labels.create(container, { supportHighlights: true, supportIcons: true }));9091const actionBarContainer = $('div.agent-feedback-hover-action-bar');92label.element.appendChild(actionBarContainer);93const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));9495return { label, actionBar, templateDisposables };96}9798renderElement(node: ITreeNode<IFeedbackFileElement, void>, _index: number, templateData: IFeedbackFileTemplate): void {99const element = node.element;100templateData.label.element.style.display = 'flex';101102const name = basename(element.uri.path);103104105templateData.label.setResource(106{ resource: element.uri, name },107{ fileKind: FileKind.FILE },108);109110templateData.actionBar.clear();111if (this._agentFeedbackService) {112const service = this._agentFeedbackService;113const sessionResource = this._sessionResource;114templateData.actionBar.push(new Action(115'agentFeedback.removeFileComments',116localize('agentFeedbackHover.removeAll', "Remove All"),117ThemeIcon.asClassName(Codicon.close),118true,119() => {120for (const item of element.items) {121service.removeFeedback(sessionResource, item.id);122}123}124), { icon: true, label: false });125}126}127128disposeTemplate(templateData: IFeedbackFileTemplate): void {129templateData.templateDisposables.dispose();130}131}132133// --- Comment Renderer ---134135interface IFeedbackCommentTemplate {136readonly textElement: HTMLElement;137readonly row: HTMLElement;138readonly actionBar: ActionBar;139readonly templateDisposables: DisposableStore;140readonly hoverDisposable: MutableDisposable<IDisposable>;141element: IFeedbackCommentElement | undefined;142}143144class FeedbackCommentRenderer implements ITreeRenderer<IFeedbackCommentElement, void, IFeedbackCommentTemplate> {145static readonly TEMPLATE_ID = 'feedbackComment';146readonly templateId = FeedbackCommentRenderer.TEMPLATE_ID;147148constructor(149private readonly _agentFeedbackService: IAgentFeedbackService | undefined,150private readonly _sessionResource: URI,151private readonly _hoverService: IHoverService,152private readonly _languageService: ILanguageService,153) { }154155renderTemplate(container: HTMLElement): IFeedbackCommentTemplate {156const templateDisposables = new DisposableStore();157158const row = dom.append(container, $('div.agent-feedback-hover-comment-row'));159160const textElement = dom.append(row, $('div.agent-feedback-hover-comment-text'));161162const actionBarContainer = dom.append(row, $('div.agent-feedback-hover-action-bar'));163const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));164165const hoverDisposable = templateDisposables.add(new MutableDisposable());166167const templateData: IFeedbackCommentTemplate = { textElement, row, actionBar, templateDisposables, hoverDisposable, element: undefined };168169if (this._agentFeedbackService) {170const service = this._agentFeedbackService;171const sessionResource = this._sessionResource;172templateDisposables.add(dom.addDisposableListener(row, dom.EventType.CLICK, (e) => {173const data = templateData.element;174if (data) {175e.preventDefault();176e.stopPropagation();177service.revealFeedback(sessionResource, data.id);178}179}));180}181182return templateData;183}184185renderElement(node: ITreeNode<IFeedbackCommentElement, void>, _index: number, templateData: IFeedbackCommentTemplate): void {186const element = node.element;187188templateData.textElement.textContent = element.text;189templateData.element = element;190191// In read-only mode, set up a rich markdown hover with comment + code snippet192if (!this._agentFeedbackService) {193templateData.hoverDisposable.value = this._hoverService.setupDelayedHover(194templateData.row,195() => this._buildCommentHover(element),196{ groupId: 'agent-feedback-comment' }197);198}199200templateData.actionBar.clear();201if (this._agentFeedbackService) {202const service = this._agentFeedbackService;203const sessionResource = this._sessionResource;204templateData.actionBar.push(new Action(205'agentFeedback.removeComment',206localize('agentFeedbackHover.remove', "Remove"),207ThemeIcon.asClassName(Codicon.close),208true,209() => {210service.removeFeedback(sessionResource, element.id);211}212), { icon: true, label: false });213}214}215216disposeTemplate(templateData: IFeedbackCommentTemplate): void {217templateData.templateDisposables.dispose();218}219220private _buildCommentHover(element: IFeedbackCommentElement): IDelayedHoverOptions {221const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });222markdown.appendText(element.text);223224if (element.codeSelection) {225const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(element.resourceUri);226markdown.appendMarkdown('\n\n');227markdown.appendCodeblock(languageId ?? '', element.codeSelection);228}229230if (element.diffHunks) {231markdown.appendMarkdown('\n\n');232markdown.appendCodeblock('diff', element.diffHunks);233}234235return {236content: markdown,237style: HoverStyle.Pointer,238position: {239hoverPosition: HoverPosition.RIGHT,240},241};242}243}244245// --- Hover ---246247/**248* Creates the custom hover content for the "N comments" attachment.249* Uses a WorkbenchObjectTree to render files as parent nodes and comments as children,250* with per-row action bars for removal.251*/252export class AgentFeedbackHover extends Disposable {253254constructor(255private readonly _element: HTMLElement,256private readonly _attachment: IAgentFeedbackVariableEntry,257private readonly _canDelete: boolean,258@IHoverService private readonly _hoverService: IHoverService,259@IInstantiationService private readonly _instantiationService: IInstantiationService,260@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,261@ILanguageService private readonly _languageService: ILanguageService,262) {263super();264265// Show on hover (delayed)266this._store.add(this._hoverService.setupDelayedHover(267this._element,268() => this._store.add(this._buildHoverContent()),269{ groupId: 'chat-attachments' }270));271272// Show immediately on click273this._store.add(dom.addDisposableListener(this._element, dom.EventType.CLICK, (e) => {274e.preventDefault();275e.stopPropagation();276this._showHoverNow();277}));278}279280private _showHoverNow(): void {281const opts = this._buildHoverContent();282this._register(opts);283this._hoverService.showInstantHover({284...opts,285target: this._element,286});287}288289private _buildHoverContent(): IDelayedHoverOptions & IDisposable {290const disposables = new DisposableStore();291const hoverElement = $('div.agent-feedback-hover');292293// Tree container294const treeContainer = dom.append(hoverElement, $('.results.show-file-icons.file-icon-themable-tree.agent-feedback-hover-tree'));295296// Resource labels (shared across all file renderers)297const resourceLabels = disposables.add(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));298299// Build tree data300const { children, commentElements } = this._buildTreeData();301302// Create tree303const tree = disposables.add(this._instantiationService.createInstance(304WorkbenchObjectTree<FeedbackTreeElement>,305'AgentFeedbackHoverTree',306treeContainer,307new FeedbackTreeDelegate(),308[309new FeedbackFileRenderer(resourceLabels, this._canDelete ? this._agentFeedbackService : undefined, this._attachment.sessionResource),310new FeedbackCommentRenderer(this._canDelete ? this._agentFeedbackService : undefined, this._attachment.sessionResource, this._hoverService, this._languageService),311],312{313defaultIndent: 0,314alwaysConsumeMouseWheel: false,315accessibilityProvider: {316getAriaLabel: (element: FeedbackTreeElement) => {317if (isFeedbackFileElement(element)) {318return basename(element.uri.path);319}320return element.text;321},322getWidgetAriaLabel: () => localize('agentFeedbackHover.tree', "Feedback Comments"),323},324identityProvider: {325getId: (element: FeedbackTreeElement) => {326if (isFeedbackFileElement(element)) {327return `file:${element.uri.toString()}`;328}329return `comment:${element.id}`;330}331},332overrideStyles: {333listFocusBackground: undefined,334listInactiveFocusBackground: undefined,335listActiveSelectionBackground: undefined,336listFocusAndSelectionBackground: undefined,337listInactiveSelectionBackground: undefined,338listBackground: editorHoverBackground,339listFocusForeground: undefined,340treeStickyScrollBackground: editorHoverBackground,341}342}343));344345// Set tree data346tree.setChildren(null, children);347348// Layout tree: clamp to reasonable height349const ROW_HEIGHT = 22;350const MAX_ROWS = 8;351const totalRows = commentElements.length + children.length;352const treeHeight = Math.min(totalRows * ROW_HEIGHT, MAX_ROWS * ROW_HEIGHT);353tree.layout(treeHeight, 200);354treeContainer.style.height = `${treeHeight}px`;355356return {357content: hoverElement,358style: HoverStyle.Pointer,359persistence: { hideOnHover: false },360position: { hoverPosition: HoverPosition.ABOVE },361trapFocus: true,362appearance: { compact: true },363additionalClasses: ['agent-feedback-hover-container'],364dispose: () => disposables.dispose(),365};366}367368private _buildTreeData(): { children: IObjectTreeElement<FeedbackTreeElement>[]; commentElements: IFeedbackCommentElement[] } {369// Group feedback items by file370const byFile = new Map<string, { uri: URI; comments: IFeedbackCommentElement[] }>();371372for (const item of this._attachment.feedbackItems) {373const key = item.resourceUri.toString();374let group = byFile.get(key);375if (!group) {376group = { uri: item.resourceUri, comments: [] };377byFile.set(key, group);378}379group.comments.push({380type: 'comment',381id: item.id,382text: item.text,383resourceUri: item.resourceUri,384codeSelection: item.codeSelection,385diffHunks: item.diffHunks,386});387}388389const children: IObjectTreeElement<FeedbackTreeElement>[] = [];390const allComments: IFeedbackCommentElement[] = [];391392for (const [, group] of byFile) {393const fileElement: IFeedbackFileElement = {394type: 'file',395uri: group.uri,396items: group.comments,397};398399allComments.push(...group.comments);400401children.push({402element: fileElement,403collapsible: true,404collapsed: false,405children: group.comments.map(comment => ({406element: comment,407collapsible: false,408})),409});410}411412return { children, commentElements: allComments };413}414}415416417