Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationWidget.ts
5243 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 './media/chatEditingExplanationWidget.css';67import { Codicon } from '../../../../../base/common/codicons.js';8import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';9import { Event } from '../../../../../base/common/event.js';10import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../../editor/browser/editorBrowser.js';11import { EditorOption } from '../../../../../editor/common/config/editorOptions.js';12import { DetailedLineRangeMapping, LineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js';13import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js';14import { $, addDisposableListener, clearNode, getTotalWidth } from '../../../../../base/browser/dom.js';15import { ThemeIcon } from '../../../../../base/common/themables.js';16import { URI } from '../../../../../base/common/uri.js';17import { Range } from '../../../../../editor/common/core/range.js';18import { overviewRulerRangeHighlight } from '../../../../../editor/common/core/editorColorRegistry.js';19import { IEditorDecorationsCollection } from '../../../../../editor/common/editorCommon.js';20import { OverviewRulerLane } from '../../../../../editor/common/model.js';21import { themeColorFromId } from '../../../../../platform/theme/common/themeService.js';22import { ChatViewId, IChatWidget, IChatWidgetService } from '../chat.js';23import { IViewsService } from '../../../../services/views/common/viewsService.js';24import * as nls from '../../../../../nls.js';25import { IExplanationDiffInfo, IChangeExplanation as IChangeExplanationModel, IChatEditingExplanationModelManager } from './chatEditingExplanationModelManager.js';26import { autorun } from '../../../../../base/common/observable.js';2728/**29* Explanation data for a single change hunk30*/31interface IChangeExplanation {32readonly startLineNumber: number;33readonly endLineNumber: number;34explanation: string;35read: boolean;36loading: boolean;37readonly originalText: string;38readonly modifiedText: string;39}4041/**42* Gets the text content for a change43*/44function getChangeTexts(change: LineRangeMapping | DetailedLineRangeMapping, diffInfo: IExplanationDiffInfo): { originalText: string; modifiedText: string } {45const originalLines: string[] = [];46const modifiedLines: string[] = [];4748// Get original text49for (let i = change.original.startLineNumber; i < change.original.endLineNumberExclusive; i++) {50const line = diffInfo.originalModel.getLineContent(i);51originalLines.push(line);52}5354// Get modified text55for (let i = change.modified.startLineNumber; i < change.modified.endLineNumberExclusive; i++) {56const line = diffInfo.modifiedModel.getLineContent(i);57modifiedLines.push(line);58}5960return {61originalText: originalLines.join('\n'),62modifiedText: modifiedLines.join('\n')63};64}6566/**67* Groups nearby changes within a threshold number of lines68* Uses the vertical span from widget position to last line it refers to69*/70function groupNearbyChanges<T extends LineRangeMapping>(changes: readonly T[], lineThreshold: number = 5): T[][] {71if (changes.length === 0) {72return [];73}7475const groups: T[][] = [];76let currentGroup: T[] = [changes[0]];7778for (let i = 1; i < changes.length; i++) {79const firstChange = currentGroup[0];80const currentChange = changes[i];8182// Calculate vertical span from widget position (first change) to start of current change83const widgetLine = firstChange.modified.startLineNumber;84const lastLine = currentChange.modified.startLineNumber;85const verticalSpan = lastLine - widgetLine;8687if (verticalSpan <= lineThreshold) {88currentGroup.push(currentChange);89} else {90groups.push(currentGroup);91currentGroup = [currentChange];92}93}9495if (currentGroup.length > 0) {96groups.push(currentGroup);97}9899return groups;100}101102/**103* Widget that displays explanatory comments for chat-made changes104* Positioned on the right side of the editor like a speech bubble105*/106export class ChatEditingExplanationWidget extends Disposable implements IOverlayWidget {107108private static _idPool = 0;109private readonly _id: string = `chat-explanation-widget-${ChatEditingExplanationWidget._idPool++}`;110111private readonly _domNode: HTMLElement;112private readonly _headerNode: HTMLElement;113private readonly _readIndicator: HTMLElement;114private readonly _titleNode: HTMLElement;115private readonly _dismissButton: HTMLElement;116private readonly _toggleButton: HTMLElement;117private readonly _bodyNode: HTMLElement;118private readonly _explanationItems: Map<number, { item: HTMLElement; readIndicator: HTMLElement; textElement: HTMLElement }> = new Map();119120private _position: IOverlayWidgetPosition | null = null;121private _explanations: IChangeExplanation[] = [];122private _isExpanded: boolean = true;123private _isAllRead: boolean = false;124private _disposed: boolean = false;125private _startLineNumber: number = 1;126private readonly _uri: URI;127private readonly _rangeHighlightDecoration: IEditorDecorationsCollection;128129private readonly _eventStore = this._register(new DisposableStore());130131constructor(132private readonly _editor: ICodeEditor,133private _changes: readonly (LineRangeMapping | DetailedLineRangeMapping)[],134diffInfo: IExplanationDiffInfo,135private readonly _chatWidgetService: IChatWidgetService,136private readonly _viewsService: IViewsService,137private readonly _chatSessionResource?: URI,138) {139super();140141this._uri = diffInfo.modifiedModel.uri;142143// Create decoration collection for range highlighting on hover144this._rangeHighlightDecoration = this._editor.createDecorationsCollection();145146// Build explanations from changes with loading state147this._explanations = this._changes.map(change => {148const { originalText, modifiedText } = getChangeTexts(change, diffInfo);149return {150startLineNumber: change.modified.startLineNumber,151endLineNumber: change.modified.endLineNumberExclusive - 1,152explanation: nls.localize('generatingExplanation', "Generating explanation..."),153read: false,154loading: true,155originalText,156modifiedText,157};158});159160// Create DOM structure161this._domNode = $('div.chat-explanation-widget');162163// Header164this._headerNode = $('div.chat-explanation-header');165166// Read indicator (checkbox-like)167this._readIndicator = $('div.chat-explanation-read-indicator');168this._updateReadIndicator();169this._headerNode.appendChild(this._readIndicator);170171// Title showing change count172this._titleNode = $('span.chat-explanation-title');173this._updateTitle();174this._headerNode.appendChild(this._titleNode);175176// Spacer177this._headerNode.appendChild($('span.chat-explanation-spacer'));178179// Toggle expand/collapse button180this._toggleButton = $('div.chat-explanation-toggle');181this._updateToggleButton();182this._headerNode.appendChild(this._toggleButton);183184// Dismiss button185this._dismissButton = $('div.chat-explanation-dismiss');186this._dismissButton.appendChild(renderIcon(Codicon.close));187this._dismissButton.title = nls.localize('dismiss', "Dismiss");188this._headerNode.appendChild(this._dismissButton);189190this._domNode.appendChild(this._headerNode);191192// Body (collapsible)193this._bodyNode = $('div.chat-explanation-body');194// Body starts expanded by default195this._buildExplanationItems();196this._domNode.appendChild(this._bodyNode);197198// Arrow pointer199const arrow = $('div.chat-explanation-arrow');200this._domNode.appendChild(arrow);201202// Event handlers203this._setupEventHandlers();204205// Add visible class for initial display206this._domNode.classList.add('visible');207208// Add to editor209this._editor.addOverlayWidget(this);210}211212private _setupEventHandlers(): void {213// Read indicator click - toggle all read/unread214this._eventStore.add(addDisposableListener(this._readIndicator, 'click', (e) => {215e.stopPropagation();216this._isAllRead = !this._isAllRead;217for (const exp of this._explanations) {218exp.read = this._isAllRead;219}220this._updateReadIndicator();221this._updateExplanationItemsReadState();222}));223224// Toggle button click - expand/collapse225this._eventStore.add(addDisposableListener(this._toggleButton, 'click', (e) => {226e.stopPropagation();227this._toggleExpanded();228}));229230// Header click - also toggles expand/collapse231this._eventStore.add(addDisposableListener(this._headerNode, 'click', () => {232this._toggleExpanded();233}));234235// Dismiss button click236this._eventStore.add(addDisposableListener(this._dismissButton, 'click', (e) => {237e.stopPropagation();238this._dismiss();239}));240}241242private _toggleExpanded(): void {243this._isExpanded = !this._isExpanded;244this._bodyNode.classList.toggle('collapsed', !this._isExpanded);245this._updateToggleButton();246this._editor.layoutOverlayWidget(this);247}248249private _dismiss(): void {250this._domNode.classList.add('fadeOut');251252const dispose = () => {253this.dispose();254};255256// Listen for animation end257const handle = setTimeout(dispose, 150);258this._domNode.addEventListener('animationend', () => {259clearTimeout(handle);260dispose();261}, { once: true });262}263264private _updateReadIndicator(): void {265clearNode(this._readIndicator);266const allRead = this._explanations.every(e => e.read);267const someRead = this._explanations.some(e => e.read);268this._isAllRead = allRead;269270if (allRead) {271this._readIndicator.appendChild(renderIcon(Codicon.circle));272this._readIndicator.classList.add('read');273this._readIndicator.classList.remove('partial', 'unread');274this._readIndicator.title = nls.localize('markAsUnread', "Mark as unread");275} else if (someRead) {276this._readIndicator.appendChild(renderIcon(Codicon.circleFilled));277this._readIndicator.classList.remove('read', 'unread');278this._readIndicator.classList.add('partial');279this._readIndicator.title = nls.localize('markAllAsRead', "Mark all as read");280} else {281this._readIndicator.appendChild(renderIcon(Codicon.circleFilled));282this._readIndicator.classList.remove('read', 'partial');283this._readIndicator.classList.add('unread');284this._readIndicator.title = nls.localize('markAsRead', "Mark as read");285}286}287288private _updateTitle(): void {289const count = this._explanations.length;290if (count === 1) {291this._titleNode.textContent = nls.localize('oneChange', "1 change");292} else {293this._titleNode.textContent = nls.localize('nChanges', "{0} changes", count);294}295}296297private _updateToggleButton(): void {298clearNode(this._toggleButton);299if (this._isExpanded) {300this._toggleButton.appendChild(renderIcon(Codicon.chevronUp));301this._toggleButton.title = nls.localize('collapse', "Collapse");302} else {303this._toggleButton.appendChild(renderIcon(Codicon.chevronDown));304this._toggleButton.title = nls.localize('expand', "Expand");305}306}307308private _buildExplanationItems(): void {309clearNode(this._bodyNode);310this._explanationItems.clear();311312for (let i = 0; i < this._explanations.length; i++) {313const exp = this._explanations[i];314const item = $('div.chat-explanation-item');315316// Line indicator317const lineInfo = $('span.chat-explanation-line-info');318if (exp.startLineNumber === exp.endLineNumber) {319lineInfo.textContent = nls.localize('lineNumber', "Line {0}", exp.startLineNumber);320} else {321lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", exp.startLineNumber, exp.endLineNumber);322}323item.appendChild(lineInfo);324325// Explanation text with loading indicator326const text = $('span.chat-explanation-text');327if (exp.loading) {328const loadingIcon = renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'));329loadingIcon.classList.add('chat-explanation-loading');330text.appendChild(loadingIcon);331const loadingText = document.createTextNode(' ' + exp.explanation);332text.appendChild(loadingText);333} else {334text.textContent = exp.explanation;335}336item.appendChild(text);337338// Item read indicator339const itemReadIndicator = $('div.chat-explanation-item-read');340this._updateItemReadIndicator(itemReadIndicator, exp.read);341item.appendChild(itemReadIndicator);342343// Reply button to add context to chat344const replyButton = $('div.chat-explanation-reply-button');345replyButton.appendChild(renderIcon(Codicon.arrowRight));346replyButton.title = nls.localize('followUpOnChange', "Follow up on this change");347item.appendChild(replyButton);348349// Reply button click handler350this._eventStore.add(addDisposableListener(replyButton, 'click', async (e) => {351e.stopPropagation();352const range = new Range(exp.startLineNumber, 1, exp.endLineNumber, 1);353let chatWidget: IChatWidget | undefined;354if (this._chatSessionResource) {355chatWidget = await this._chatWidgetService.openSession(this._chatSessionResource);356} else {357await this._viewsService.openView(ChatViewId, true);358chatWidget = this._chatWidgetService.lastFocusedWidget;359}360if (chatWidget) {361chatWidget.attachmentModel.addContext(362chatWidget.attachmentModel.asFileVariableEntry(this._uri, range)363);364}365}));366367// Click on item to mark as read368this._eventStore.add(addDisposableListener(item, 'click', (e) => {369e.stopPropagation();370exp.read = !exp.read;371this._updateItemReadIndicator(itemReadIndicator, exp.read);372this._updateReadIndicator();373}));374375// Hover handlers for range highlighting376this._eventStore.add(addDisposableListener(item, 'mouseenter', () => {377const range = new Range(exp.startLineNumber, 1, exp.endLineNumber, this._editor.getModel()?.getLineMaxColumn(exp.endLineNumber) ?? 1);378this._rangeHighlightDecoration.set([379// Line highlight with gutter decoration380{381range,382options: {383description: 'chat-explanation-range-highlight',384className: 'rangeHighlight',385isWholeLine: true,386linesDecorationsClassName: 'chat-explanation-range-glyph',387}388},389// Overview ruler indicator390{391range,392options: {393description: 'chat-explanation-range-highlight-overview',394overviewRuler: {395color: themeColorFromId(overviewRulerRangeHighlight),396position: OverviewRulerLane.Full,397}398}399}400]);401}));402403this._eventStore.add(addDisposableListener(item, 'mouseleave', () => {404this._rangeHighlightDecoration.clear();405}));406407this._explanationItems.set(i, { item, readIndicator: itemReadIndicator, textElement: text });408this._bodyNode.appendChild(item);409}410}411412/**413* Sets the explanation for a change matching the given line number range.414* @returns true if a matching explanation was found and updated415*/416setExplanationByLineNumber(startLineNumber: number, endLineNumber: number, explanation: string): boolean {417for (let i = 0; i < this._explanations.length; i++) {418const exp = this._explanations[i];419if (exp.startLineNumber === startLineNumber && exp.endLineNumber === endLineNumber) {420exp.explanation = explanation;421exp.loading = false;422this._updateExplanationText(i);423return true;424}425}426return false;427}428429/**430* Gets the number of explanations in this widget.431*/432get explanationCount(): number {433return this._explanations.length;434}435436private _updateExplanationText(index: number): void {437const itemData = this._explanationItems.get(index);438const exp = this._explanations[index];439if (itemData && exp) {440clearNode(itemData.textElement);441itemData.textElement.textContent = exp.explanation;442}443}444445private _updateItemReadIndicator(element: HTMLElement, read: boolean): void {446clearNode(element);447if (read) {448element.appendChild(renderIcon(Codicon.circle));449element.classList.add('read');450element.classList.remove('unread');451} else {452element.appendChild(renderIcon(Codicon.circleFilled));453element.classList.remove('read');454element.classList.add('unread');455}456}457458private _updateExplanationItemsReadState(): void {459this._explanationItems.forEach(({ readIndicator }, index) => {460const exp = this._explanations[index];461this._updateItemReadIndicator(readIndicator, exp.read);462});463}464465/**466* Updates the widget position and layout467*/468layout(startLineNumber: number): void {469if (this._disposed) {470return;471}472473this._startLineNumber = startLineNumber;474475const lineHeight = this._editor.getOption(EditorOption.lineHeight);476const { contentLeft, contentWidth, verticalScrollbarWidth } = this._editor.getLayoutInfo();477const scrollTop = this._editor.getScrollTop();478479// Position at right edge like DiffHunkWidget480const widgetWidth = getTotalWidth(this._domNode) || 280;481482this._position = {483stackOrdinal: 2,484preference: {485top: this._editor.getTopForLineNumber(startLineNumber) - scrollTop - lineHeight,486left: contentLeft + contentWidth - (2 * verticalScrollbarWidth + widgetWidth)487}488};489490this._editor.layoutOverlayWidget(this);491}492493/**494* Shows or hides the widget495*/496toggle(show: boolean): void {497this._domNode.classList.toggle('visible', show);498if (show && this._explanations.length > 0) {499this.layout(this._explanations[0].startLineNumber);500}501}502503/**504* Relayouts the widget at its current line number505*/506relayout(): void {507if (this._startLineNumber) {508this.layout(this._startLineNumber);509}510}511512// IOverlayWidget implementation513514getId(): string {515return this._id;516}517518getDomNode(): HTMLElement {519return this._domNode;520}521522getPosition(): IOverlayWidgetPosition | null {523return this._position;524}525526override dispose(): void {527if (this._disposed) {528return;529}530this._disposed = true;531this._rangeHighlightDecoration.clear();532this._editor.removeOverlayWidget(this);533super.dispose();534}535}536537/**538* Manager for explanation widgets in an editor539* Groups changes and creates combined widgets for nearby changes540*/541export class ChatEditingExplanationWidgetManager extends Disposable {542543private readonly _widgets: ChatEditingExplanationWidget[] = [];544private _visible: boolean = false;545546private _chatSessionResource: URI | undefined;547private _diffInfo: IExplanationDiffInfo | undefined;548549constructor(550private readonly _editor: ICodeEditor,551private readonly _chatWidgetService: IChatWidgetService,552private readonly _viewsService: IViewsService,553modelManager: IChatEditingExplanationModelManager,554private readonly _modelUri: URI,555) {556super();557558// Listen for model changes - hide/show widgets based on whether current model matches559this._register(this._editor.onDidChangeModel(() => {560const newUri = this._editor.getModel()?.uri;561if (this._modelUri) {562if (newUri && newUri.toString() === this._modelUri.toString()) {563// Switched back to the file - show widgets564for (const widget of this._widgets) {565widget.toggle(this._visible);566widget.relayout();567}568} else {569// Switched to a different file - hide widgets570for (const widget of this._widgets) {571widget.toggle(false);572}573}574}575}));576577// Observe state from model manager578this._register(autorun(r => {579const state = modelManager.state.read(r);580const uriState = state.get(this._modelUri);581582if (uriState) {583// Update diffInfo and chatSessionResource from state584this._diffInfo = uriState.diffInfo;585this._chatSessionResource = uriState.chatSessionResource;586587// Ensure widgets are created588if (this._widgets.length === 0 && this._diffInfo) {589this._createWidgets(this._diffInfo, this._chatSessionResource);590}591// Handle explanation state changes592if (uriState.progress === 'complete') {593this._handleExplanations(this._modelUri, uriState.explanations);594}595this.show();596} else {597this.hide();598}599}));600}601602private _createWidgets(diffInfo: IExplanationDiffInfo, chatSessionResource: URI | undefined): void {603if (diffInfo.identical || diffInfo.changes.length === 0) {604return;605}606607// Group nearby changes608const groups = groupNearbyChanges(diffInfo.changes, 5);609610// Create a widget for each group611for (const group of groups) {612const widget = new ChatEditingExplanationWidget(613this._editor,614group,615diffInfo,616this._chatWidgetService,617this._viewsService,618chatSessionResource,619);620this._widgets.push(widget);621this._register(widget);622623// Layout at the first change in the group624widget.layout(group[0].modified.startLineNumber);625}626627// Relayout on scroll/layout changes628this._register(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => {629for (const widget of this._widgets) {630widget.relayout();631}632}));633}634635private _handleExplanations(uri: URI, explanations: readonly IChangeExplanationModel[]): void {636if (!this._modelUri || uri.toString() !== this._modelUri.toString()) {637return;638}639640// Map explanations to widgets by matching line numbers641for (const explanation of explanations) {642for (const widget of this._widgets) {643// Try to set the explanation on the widget - it will match by line number644if (widget.setExplanationByLineNumber(645explanation.startLineNumber,646explanation.endLineNumber,647explanation.explanation648)) {649break; // Found the matching widget, no need to check others650}651}652}653}654655/**656* Shows all widgets657*/658show(): void {659this._visible = true;660for (const widget of this._widgets) {661widget.toggle(true);662widget.relayout();663}664}665666/**667* Hides all widgets668*/669hide(): void {670this._visible = false;671for (const widget of this._widgets) {672widget.toggle(false);673}674}675676private _clearWidgets(): void {677for (const widget of this._widgets) {678widget.dispose();679}680this._widgets.length = 0;681}682683override dispose(): void {684this._clearWidgets();685super.dispose();686}687}688689690