mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-12 20:12:23 +01:00
Merge pull request #296555 from microsoft/benibenj/fresh-chinchilla
sessions feedback improvements
This commit is contained in:
@@ -310,6 +310,7 @@ export enum RenderIndentGuides {
|
||||
|
||||
interface ITreeRendererOptions<T> {
|
||||
readonly indent?: number;
|
||||
readonly defaultIndent?: number;
|
||||
readonly renderIndentGuides?: RenderIndentGuides;
|
||||
// TODO@joao replace this with collapsible: boolean | 'ondemand'
|
||||
readonly hideTwistiesOfChildlessElements?: boolean;
|
||||
@@ -347,6 +348,7 @@ export class TreeRenderer<T, TFilterData, TRef, TTemplateData> implements IListR
|
||||
private renderedElements = new Map<T, ITreeNode<T, TFilterData>>();
|
||||
private renderedNodes = new Map<ITreeNode<T, TFilterData>, ITreeListTemplateData<TTemplateData>>();
|
||||
private indent: number = TreeRenderer.DefaultIndent;
|
||||
private defaultIndent: number = TreeRenderer.DefaultIndent;
|
||||
private hideTwistiesOfChildlessElements: boolean = false;
|
||||
private twistieAdditionalCssClass?: (element: T) => string | undefined;
|
||||
|
||||
@@ -372,14 +374,19 @@ export class TreeRenderer<T, TFilterData, TRef, TTemplateData> implements IListR
|
||||
}
|
||||
|
||||
updateOptions(options: ITreeRendererOptions<T> = {}): void {
|
||||
if (typeof options.indent !== 'undefined') {
|
||||
const indent = clamp(options.indent, 0, 40);
|
||||
if (typeof options.defaultIndent !== 'undefined') {
|
||||
this.defaultIndent = options.defaultIndent;
|
||||
}
|
||||
|
||||
if (indent !== this.indent) {
|
||||
if (typeof options.indent !== 'undefined' || typeof options.defaultIndent !== 'undefined') {
|
||||
const indent = typeof options.indent !== 'undefined' ? clamp(options.indent, 0, 40) : this.indent;
|
||||
const needsRerender = indent !== this.indent || typeof options.defaultIndent !== 'undefined';
|
||||
|
||||
if (needsRerender) {
|
||||
this.indent = indent;
|
||||
|
||||
for (const [node, templateData] of this.renderedNodes) {
|
||||
templateData.indentSize = TreeRenderer.DefaultIndent + (node.depth - 1) * this.indent;
|
||||
templateData.indentSize = this.defaultIndent + (node.depth - 1) * this.indent;
|
||||
this.renderTreeElement(node, templateData);
|
||||
}
|
||||
}
|
||||
@@ -427,7 +434,7 @@ export class TreeRenderer<T, TFilterData, TRef, TTemplateData> implements IListR
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<T, TFilterData>, index: number, templateData: ITreeListTemplateData<TTemplateData>, details?: IListElementRenderDetails): void {
|
||||
templateData.indentSize = TreeRenderer.DefaultIndent + (node.depth - 1) * this.indent;
|
||||
templateData.indentSize = this.defaultIndent + (node.depth - 1) * this.indent;
|
||||
|
||||
this.renderedNodes.set(node, templateData);
|
||||
this.renderedElements.set(node.element, node);
|
||||
@@ -2192,6 +2199,7 @@ function asTreeContextMenuEvent<T, TFilterData = void>(event: IListContextMenuEv
|
||||
}
|
||||
|
||||
export interface IAbstractTreeOptionsUpdate<T> extends ITreeRendererOptions<T> {
|
||||
readonly defaultIndent?: number; // Only recommended for compact layouts. Leave unchanged otherwise
|
||||
readonly multipleSelectionSupport?: boolean;
|
||||
readonly typeNavigationEnabled?: boolean;
|
||||
readonly typeNavigationMode?: TypeNavigationMode;
|
||||
|
||||
@@ -1065,9 +1065,15 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService {
|
||||
!hidden,
|
||||
);
|
||||
|
||||
// If sidebar becomes hidden, also hide the current active pane composite
|
||||
if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) {
|
||||
this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar);
|
||||
}
|
||||
|
||||
// If sidebar becomes visible, show last active Viewlet or default viewlet
|
||||
if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) {
|
||||
const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar);
|
||||
const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar) ??
|
||||
this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Sidebar)?.id;
|
||||
if (viewletToOpen) {
|
||||
this.paneCompositeService.openPaneComposite(viewletToOpen, ViewContainerLocation.Sidebar);
|
||||
}
|
||||
@@ -1088,9 +1094,15 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService {
|
||||
!hidden,
|
||||
);
|
||||
|
||||
// If auxiliary bar becomes visible, show last active pane composite
|
||||
// If auxiliary bar becomes hidden, also hide the current active pane composite
|
||||
if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) {
|
||||
this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar);
|
||||
}
|
||||
|
||||
// If auxiliary bar becomes visible, show last active pane composite or default
|
||||
if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) {
|
||||
const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar);
|
||||
const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar) ??
|
||||
this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.AuxiliaryBar)?.id;
|
||||
if (paneCompositeToOpen) {
|
||||
this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.AuxiliaryBar);
|
||||
}
|
||||
@@ -1125,9 +1137,15 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService {
|
||||
!hidden,
|
||||
);
|
||||
|
||||
// If panel becomes visible, show last active panel
|
||||
// If panel becomes hidden, also hide the current active pane composite
|
||||
if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) {
|
||||
this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel);
|
||||
}
|
||||
|
||||
// If panel becomes visible, show last active panel or default
|
||||
if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) {
|
||||
const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel);
|
||||
const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel) ??
|
||||
this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id;
|
||||
if (panelToOpen) {
|
||||
this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './agentFeedbackEditorInputContribution.js';
|
||||
import './agentFeedbackEditorWidgetContribution.js';
|
||||
import './agentFeedbackLineDecorationContribution.js';
|
||||
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
|
||||
import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedbackService.js';
|
||||
import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js';
|
||||
@@ -25,8 +27,11 @@ registerSingleton(IAgentFeedbackService, AgentFeedbackService, InstantiationType
|
||||
// Register the custom attachment widget for agentFeedback attachments
|
||||
class AgentFeedbackAttachmentWidgetContribution {
|
||||
static readonly ID = 'workbench.contrib.agentFeedbackAttachmentWidgetFactory';
|
||||
constructor(@IChatAttachmentWidgetRegistry registry: IChatAttachmentWidgetRegistry) {
|
||||
registry.registerFactory('agentFeedback', (instantiationService, attachment, options, container) => {
|
||||
constructor(
|
||||
@IChatAttachmentWidgetRegistry registry: IChatAttachmentWidgetRegistry,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
) {
|
||||
registry.registerFactory('agentFeedback', (attachment, options, container) => {
|
||||
return instantiationService.createInstance(AgentFeedbackAttachmentWidget, attachment as IAgentFeedbackVariableEntry, options, container);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ export class AgentFeedbackAttachmentWidget extends Disposable {
|
||||
const label = dom.$('span.chat-attached-context-custom-text', {}, this._attachment.name);
|
||||
this.element.appendChild(label);
|
||||
|
||||
const deletionCurrentlyNotSupported = true;
|
||||
|
||||
// Clear button
|
||||
if (options.supportsDeletion) {
|
||||
if (options.supportsDeletion && !deletionCurrentlyNotSupported) {
|
||||
const clearBtn = dom.append(this.element, dom.$('.chat-attached-context-clear-button'));
|
||||
const clearIcon = dom.$('span');
|
||||
clearIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close));
|
||||
|
||||
@@ -131,7 +131,6 @@ class NavigateFeedbackAction extends AgentFeedbackEditorAction {
|
||||
editorService.openEditor({
|
||||
resource: feedback.resourceUri,
|
||||
options: {
|
||||
selection: feedback.range,
|
||||
preserveFocus: false,
|
||||
revealIfVisible: true,
|
||||
}
|
||||
|
||||
+47
-5
@@ -22,12 +22,16 @@ import { localize } from '../../../../nls.js';
|
||||
class AgentFeedbackInputWidget implements IOverlayWidget {
|
||||
|
||||
private static readonly _ID = 'agentFeedback.inputWidget';
|
||||
private static readonly _MIN_WIDTH = 150;
|
||||
private static readonly _MAX_WIDTH = 400;
|
||||
|
||||
readonly allowEditorOverflow = false;
|
||||
|
||||
private readonly _domNode: HTMLElement;
|
||||
private readonly _inputElement: HTMLInputElement;
|
||||
private readonly _inputElement: HTMLTextAreaElement;
|
||||
private readonly _measureElement: HTMLElement;
|
||||
private _position: IOverlayWidgetPosition | null = null;
|
||||
private _lineHeight = 0;
|
||||
|
||||
constructor(
|
||||
private readonly _editor: ICodeEditor,
|
||||
@@ -36,12 +40,19 @@ class AgentFeedbackInputWidget implements IOverlayWidget {
|
||||
this._domNode.classList.add('agent-feedback-input-widget');
|
||||
this._domNode.style.display = 'none';
|
||||
|
||||
this._inputElement = document.createElement('input');
|
||||
this._inputElement.type = 'text';
|
||||
this._inputElement = document.createElement('textarea');
|
||||
this._inputElement.rows = 1;
|
||||
this._inputElement.placeholder = localize('agentFeedback.addFeedback', "Add Feedback");
|
||||
this._domNode.appendChild(this._inputElement);
|
||||
|
||||
// Hidden element used to measure text width for auto-growing
|
||||
this._measureElement = document.createElement('span');
|
||||
this._measureElement.classList.add('agent-feedback-input-measure');
|
||||
this._domNode.appendChild(this._measureElement);
|
||||
|
||||
this._editor.applyFontInfo(this._inputElement);
|
||||
this._editor.applyFontInfo(this._measureElement);
|
||||
this._lineHeight = this._editor.getOption(EditorOption.lineHeight);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -56,7 +67,7 @@ class AgentFeedbackInputWidget implements IOverlayWidget {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
get inputElement(): HTMLInputElement {
|
||||
get inputElement(): HTMLTextAreaElement {
|
||||
return this._inputElement;
|
||||
}
|
||||
|
||||
@@ -75,6 +86,28 @@ class AgentFeedbackInputWidget implements IOverlayWidget {
|
||||
|
||||
clearInput(): void {
|
||||
this._inputElement.value = '';
|
||||
this._autoSize();
|
||||
}
|
||||
|
||||
autoSize(): void {
|
||||
this._autoSize();
|
||||
}
|
||||
|
||||
private _autoSize(): void {
|
||||
const text = this._inputElement.value || this._inputElement.placeholder;
|
||||
|
||||
// Measure the text width using the hidden span
|
||||
this._measureElement.textContent = text;
|
||||
const textWidth = this._measureElement.scrollWidth;
|
||||
|
||||
// Clamp width between min and max
|
||||
const width = Math.max(AgentFeedbackInputWidget._MIN_WIDTH, Math.min(textWidth + 10, AgentFeedbackInputWidget._MAX_WIDTH));
|
||||
this._inputElement.style.width = `${width}px`;
|
||||
|
||||
// Reset height to auto then expand to fit all content, with a minimum of 1 line
|
||||
this._inputElement.style.height = 'auto';
|
||||
const newHeight = Math.max(this._inputElement.scrollHeight, this._lineHeight + 4 /* padding */);
|
||||
this._inputElement.style.height = `${newHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +143,11 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements
|
||||
this._mouseDown = true;
|
||||
this._hide();
|
||||
}));
|
||||
this._store.add(this._editor.onMouseUp(() => {
|
||||
this._store.add(this._editor.onMouseUp((e) => {
|
||||
this._mouseDown = false;
|
||||
if (this._isWidgetTarget(e.event.target)) {
|
||||
return;
|
||||
}
|
||||
this._onSelectionChanged();
|
||||
}));
|
||||
this._store.add(this._editor.onDidBlurEditorWidget(() => {
|
||||
@@ -262,6 +298,12 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements
|
||||
e.stopPropagation();
|
||||
}));
|
||||
|
||||
// Auto-size the textarea as the user types
|
||||
this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'input', () => {
|
||||
widget.autoSize();
|
||||
this._updatePosition();
|
||||
}));
|
||||
|
||||
// Hide when input loses focus to something outside both editor and widget
|
||||
this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'blur', () => {
|
||||
const win = getWindow(widget.inputElement);
|
||||
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/agentFeedbackEditorWidget.css';
|
||||
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { Event } from '../../../../base/common/event.js';
|
||||
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';
|
||||
import { IEditorContribution, IEditorDecorationsCollection, ScrollType } from '../../../../editor/common/editorCommon.js';
|
||||
import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js';
|
||||
import { EditorOption } from '../../../../editor/common/config/editorOptions.js';
|
||||
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
|
||||
import { $, addDisposableListener, clearNode, getTotalWidth } from '../../../../base/browser/dom.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { Range } from '../../../../editor/common/core/range.js';
|
||||
import { overviewRulerRangeHighlight } from '../../../../editor/common/core/editorColorRegistry.js';
|
||||
import { OverviewRulerLane } from '../../../../editor/common/model.js';
|
||||
import { themeColorFromId } from '../../../../platform/theme/common/themeService.js';
|
||||
import * as nls from '../../../../nls.js';
|
||||
import { IAgentFeedback, IAgentFeedbackService } from './agentFeedbackService.js';
|
||||
import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
|
||||
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
|
||||
import { getSessionForResource } from './agentFeedbackEditorUtils.js';
|
||||
|
||||
/**
|
||||
* Groups nearby feedback items within a threshold number of lines.
|
||||
*/
|
||||
function groupNearbyFeedback(items: readonly IAgentFeedback[], lineThreshold: number = 5): IAgentFeedback[][] {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort by start line number
|
||||
const sorted = [...items].sort((a, b) => a.range.startLineNumber - b.range.startLineNumber);
|
||||
|
||||
const groups: IAgentFeedback[][] = [];
|
||||
let currentGroup: IAgentFeedback[] = [sorted[0]];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const firstItem = currentGroup[0];
|
||||
const currentItem = sorted[i];
|
||||
|
||||
const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber;
|
||||
|
||||
if (verticalSpan <= lineThreshold) {
|
||||
currentGroup.push(currentItem);
|
||||
} else {
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [currentItem];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget that displays agent feedback comments for a group of nearby feedback items.
|
||||
* Positioned on the right side of the editor like a speech bubble.
|
||||
*/
|
||||
export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWidget {
|
||||
|
||||
private static _idPool = 0;
|
||||
private readonly _id: string = `agent-feedback-widget-${AgentFeedbackEditorWidget._idPool++}`;
|
||||
|
||||
private readonly _domNode: HTMLElement;
|
||||
private readonly _headerNode: HTMLElement;
|
||||
private readonly _titleNode: HTMLElement;
|
||||
private readonly _dismissButton: HTMLElement;
|
||||
private readonly _toggleButton: HTMLElement;
|
||||
private readonly _bodyNode: HTMLElement;
|
||||
private readonly _itemElements = new Map<string, HTMLElement>();
|
||||
|
||||
private _position: IOverlayWidgetPosition | null = null;
|
||||
private _isExpanded: boolean = false;
|
||||
private _disposed: boolean = false;
|
||||
private _startLineNumber: number = 1;
|
||||
private readonly _rangeHighlightDecoration: IEditorDecorationsCollection;
|
||||
|
||||
private readonly _eventStore = this._register(new DisposableStore());
|
||||
|
||||
constructor(
|
||||
private readonly _editor: ICodeEditor,
|
||||
private readonly _feedbackItems: readonly IAgentFeedback[],
|
||||
private readonly _agentFeedbackService: IAgentFeedbackService,
|
||||
private readonly _sessionResource: URI,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._rangeHighlightDecoration = this._editor.createDecorationsCollection();
|
||||
|
||||
// Create DOM structure
|
||||
this._domNode = $('div.agent-feedback-widget');
|
||||
this._domNode.classList.add('collapsed');
|
||||
|
||||
// Header
|
||||
this._headerNode = $('div.agent-feedback-widget-header');
|
||||
|
||||
// Title showing feedback count
|
||||
this._titleNode = $('span.agent-feedback-widget-title');
|
||||
this._updateTitle();
|
||||
this._headerNode.appendChild(this._titleNode);
|
||||
|
||||
// Spacer
|
||||
this._headerNode.appendChild($('span.agent-feedback-widget-spacer'));
|
||||
|
||||
// Toggle expand/collapse button
|
||||
this._toggleButton = $('div.agent-feedback-widget-toggle');
|
||||
this._updateToggleButton();
|
||||
this._headerNode.appendChild(this._toggleButton);
|
||||
|
||||
// Dismiss button
|
||||
this._dismissButton = $('div.agent-feedback-widget-dismiss');
|
||||
this._dismissButton.appendChild(renderIcon(Codicon.close));
|
||||
this._dismissButton.title = nls.localize('dismiss', "Dismiss");
|
||||
this._headerNode.appendChild(this._dismissButton);
|
||||
|
||||
this._domNode.appendChild(this._headerNode);
|
||||
|
||||
// Body (collapsible) — starts collapsed
|
||||
this._bodyNode = $('div.agent-feedback-widget-body');
|
||||
this._bodyNode.classList.add('collapsed');
|
||||
this._buildFeedbackItems();
|
||||
this._domNode.appendChild(this._bodyNode);
|
||||
|
||||
// Arrow pointer
|
||||
const arrow = $('div.agent-feedback-widget-arrow');
|
||||
this._domNode.appendChild(arrow);
|
||||
|
||||
// Event handlers
|
||||
this._setupEventHandlers();
|
||||
|
||||
// Add visible class for initial display
|
||||
this._domNode.classList.add('visible');
|
||||
|
||||
// Add to editor
|
||||
this._editor.addOverlayWidget(this);
|
||||
}
|
||||
|
||||
private _setupEventHandlers(): void {
|
||||
// Toggle button click - expand/collapse
|
||||
this._eventStore.add(addDisposableListener(this._toggleButton, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggleExpanded();
|
||||
}));
|
||||
|
||||
// Header click - also toggles expand/collapse
|
||||
this._eventStore.add(addDisposableListener(this._headerNode, 'click', () => {
|
||||
this._toggleExpanded();
|
||||
}));
|
||||
|
||||
// Dismiss button click
|
||||
this._eventStore.add(addDisposableListener(this._dismissButton, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._dismiss();
|
||||
}));
|
||||
}
|
||||
|
||||
private _toggleExpanded(): void {
|
||||
if (this._isExpanded) {
|
||||
this.collapse();
|
||||
} else {
|
||||
this.expand();
|
||||
}
|
||||
}
|
||||
|
||||
private _dismiss(): void {
|
||||
// Remove all feedback items in this widget from the service
|
||||
for (const feedback of this._feedbackItems) {
|
||||
this._agentFeedbackService.removeFeedback(this._sessionResource, feedback.id);
|
||||
}
|
||||
|
||||
this._domNode.classList.add('fadeOut');
|
||||
|
||||
const dispose = () => {
|
||||
this.dispose();
|
||||
};
|
||||
|
||||
const handle = setTimeout(dispose, 150);
|
||||
this._domNode.addEventListener('animationend', () => {
|
||||
clearTimeout(handle);
|
||||
dispose();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
private _updateTitle(): void {
|
||||
const count = this._feedbackItems.length;
|
||||
if (count === 1) {
|
||||
this._titleNode.textContent = nls.localize('oneComment', "1 comment");
|
||||
} else {
|
||||
this._titleNode.textContent = nls.localize('nComments', "{0} comments", count);
|
||||
}
|
||||
}
|
||||
|
||||
private _updateToggleButton(): void {
|
||||
clearNode(this._toggleButton);
|
||||
if (this._isExpanded) {
|
||||
this._toggleButton.appendChild(renderIcon(Codicon.chevronUp));
|
||||
this._toggleButton.title = nls.localize('collapse', "Collapse");
|
||||
} else {
|
||||
this._toggleButton.appendChild(renderIcon(Codicon.chevronDown));
|
||||
this._toggleButton.title = nls.localize('expand', "Expand");
|
||||
}
|
||||
}
|
||||
|
||||
private _buildFeedbackItems(): void {
|
||||
clearNode(this._bodyNode);
|
||||
this._itemElements.clear();
|
||||
|
||||
for (const feedback of this._feedbackItems) {
|
||||
const item = $('div.agent-feedback-widget-item');
|
||||
this._itemElements.set(feedback.id, item);
|
||||
|
||||
// Line indicator
|
||||
const lineInfo = $('span.agent-feedback-widget-line-info');
|
||||
if (feedback.range.startLineNumber === feedback.range.endLineNumber) {
|
||||
lineInfo.textContent = nls.localize('lineNumber', "Line {0}", feedback.range.startLineNumber);
|
||||
} else {
|
||||
lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", feedback.range.startLineNumber, feedback.range.endLineNumber);
|
||||
}
|
||||
item.appendChild(lineInfo);
|
||||
|
||||
// Feedback text
|
||||
const text = $('span.agent-feedback-widget-text');
|
||||
text.textContent = feedback.text;
|
||||
item.appendChild(text);
|
||||
|
||||
// Hover handlers for range highlighting
|
||||
this._eventStore.add(addDisposableListener(item, 'mouseenter', () => {
|
||||
this._highlightRange(feedback);
|
||||
}));
|
||||
|
||||
this._eventStore.add(addDisposableListener(item, 'mouseleave', () => {
|
||||
this._rangeHighlightDecoration.clear();
|
||||
}));
|
||||
|
||||
this._bodyNode.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the widget body.
|
||||
*/
|
||||
expand(): void {
|
||||
this._isExpanded = true;
|
||||
this._domNode.classList.remove('collapsed');
|
||||
this._bodyNode.classList.remove('collapsed');
|
||||
this._updateToggleButton();
|
||||
this._editor.layoutOverlayWidget(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the widget body.
|
||||
*/
|
||||
collapse(): void {
|
||||
this._isExpanded = false;
|
||||
this._domNode.classList.add('collapsed');
|
||||
this._bodyNode.classList.add('collapsed');
|
||||
this._updateToggleButton();
|
||||
this.clearFocus();
|
||||
this._editor.layoutOverlayWidget(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a specific feedback item within this widget.
|
||||
* Highlights its range in the editor and marks it as focused.
|
||||
*/
|
||||
focusFeedback(feedbackId: string): void {
|
||||
// Clear previous focus
|
||||
for (const el of this._itemElements.values()) {
|
||||
el.classList.remove('focused');
|
||||
}
|
||||
|
||||
const feedback = this._feedbackItems.find(f => f.id === feedbackId);
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add focused class to the item
|
||||
const itemEl = this._itemElements.get(feedbackId);
|
||||
itemEl?.classList.add('focused');
|
||||
|
||||
// Show range highlighting
|
||||
this._highlightRange(feedback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear focus state and range highlighting.
|
||||
*/
|
||||
clearFocus(): void {
|
||||
for (const el of this._itemElements.values()) {
|
||||
el.classList.remove('focused');
|
||||
}
|
||||
this._rangeHighlightDecoration.clear();
|
||||
}
|
||||
|
||||
private _highlightRange(feedback: IAgentFeedback): void {
|
||||
const endLineNumber = feedback.range.endLineNumber;
|
||||
const range = new Range(
|
||||
feedback.range.startLineNumber, 1,
|
||||
endLineNumber, this._editor.getModel()?.getLineMaxColumn(endLineNumber) ?? 1
|
||||
);
|
||||
this._rangeHighlightDecoration.set([
|
||||
{
|
||||
range,
|
||||
options: {
|
||||
description: 'agent-feedback-range-highlight',
|
||||
className: 'rangeHighlight',
|
||||
isWholeLine: true,
|
||||
linesDecorationsClassName: 'agent-feedback-widget-range-glyph',
|
||||
}
|
||||
},
|
||||
{
|
||||
range,
|
||||
options: {
|
||||
description: 'agent-feedback-range-highlight-overview',
|
||||
overviewRuler: {
|
||||
color: themeColorFromId(overviewRulerRangeHighlight),
|
||||
position: OverviewRulerLane.Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this widget contains the given feedback item (by id).
|
||||
*/
|
||||
containsFeedback(feedbackId: string): boolean {
|
||||
return this._feedbackItems.some(f => f.id === feedbackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the widget position and layout.
|
||||
*/
|
||||
layout(startLineNumber: number): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._startLineNumber = startLineNumber;
|
||||
|
||||
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
|
||||
const { contentLeft, contentWidth, verticalScrollbarWidth } = this._editor.getLayoutInfo();
|
||||
const scrollTop = this._editor.getScrollTop();
|
||||
|
||||
const widgetWidth = getTotalWidth(this._domNode) || 280;
|
||||
|
||||
this._position = {
|
||||
stackOrdinal: 2,
|
||||
preference: {
|
||||
top: this._editor.getTopForLineNumber(startLineNumber) - scrollTop - lineHeight,
|
||||
left: contentLeft + contentWidth - (2 * verticalScrollbarWidth + widgetWidth)
|
||||
}
|
||||
};
|
||||
|
||||
this._editor.layoutOverlayWidget(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or hides the widget.
|
||||
*/
|
||||
toggle(show: boolean): void {
|
||||
this._domNode.classList.toggle('visible', show);
|
||||
if (show && this._feedbackItems.length > 0) {
|
||||
this.layout(this._feedbackItems[0].range.startLineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relayouts the widget at its current line number.
|
||||
*/
|
||||
relayout(): void {
|
||||
if (this._startLineNumber) {
|
||||
this.layout(this._startLineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// IOverlayWidget implementation
|
||||
|
||||
getId(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
getDomNode(): HTMLElement {
|
||||
return this._domNode;
|
||||
}
|
||||
|
||||
getPosition(): IOverlayWidgetPosition | null {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
this._disposed = true;
|
||||
this._rangeHighlightDecoration.clear();
|
||||
this._editor.removeOverlayWidget(this);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor contribution that manages agent feedback widgets.
|
||||
* Groups feedback items and creates combined widgets for nearby items.
|
||||
* Widgets start collapsed and expand when navigated to.
|
||||
*/
|
||||
class AgentFeedbackEditorWidgetContribution extends Disposable implements IEditorContribution {
|
||||
|
||||
static readonly ID = 'agentFeedback.editorWidgetContribution';
|
||||
|
||||
private readonly _widgets: AgentFeedbackEditorWidget[] = [];
|
||||
private _sessionResource: URI | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly _editor: ICodeEditor,
|
||||
@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,
|
||||
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
|
||||
@IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._store.add(this._agentFeedbackService.onDidChangeFeedback(e => {
|
||||
if (this._sessionResource && e.sessionResource.toString() === this._sessionResource.toString()) {
|
||||
this._rebuildWidgets();
|
||||
}
|
||||
}));
|
||||
|
||||
this._store.add(this._agentFeedbackService.onDidChangeNavigation(sessionResource => {
|
||||
if (this._sessionResource && sessionResource.toString() === this._sessionResource.toString()) {
|
||||
this._handleNavigation();
|
||||
}
|
||||
}));
|
||||
|
||||
this._store.add(this._editor.onDidChangeModel(() => {
|
||||
this._resolveSession();
|
||||
this._rebuildWidgets();
|
||||
}));
|
||||
|
||||
this._store.add(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => {
|
||||
for (const widget of this._widgets) {
|
||||
widget.relayout();
|
||||
}
|
||||
}));
|
||||
|
||||
this._resolveSession();
|
||||
this._rebuildWidgets();
|
||||
}
|
||||
|
||||
private _resolveSession(): void {
|
||||
const model = this._editor.getModel();
|
||||
if (!model) {
|
||||
this._sessionResource = undefined;
|
||||
return;
|
||||
}
|
||||
this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService);
|
||||
}
|
||||
|
||||
private _rebuildWidgets(): void {
|
||||
this._clearWidgets();
|
||||
|
||||
if (!this._sessionResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this._editor.getModel();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource);
|
||||
// Filter to feedback items belonging to this editor's file
|
||||
const fileFeedback = allFeedback.filter(f => f.resourceUri.toString() === model.uri.toString());
|
||||
if (fileFeedback.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = groupNearbyFeedback(fileFeedback, 5);
|
||||
|
||||
for (const group of groups) {
|
||||
const widget = new AgentFeedbackEditorWidget(this._editor, group, this._agentFeedbackService, this._sessionResource);
|
||||
this._widgets.push(widget);
|
||||
|
||||
widget.layout(group[0].range.startLineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleNavigation(): void {
|
||||
if (!this._sessionResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource);
|
||||
if (bearing.activeIdx < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource);
|
||||
const activeFeedback = allFeedback[bearing.activeIdx];
|
||||
if (!activeFeedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand the widget containing the active feedback, collapse all others
|
||||
for (const widget of this._widgets) {
|
||||
if (widget.containsFeedback(activeFeedback.id)) {
|
||||
widget.expand();
|
||||
widget.focusFeedback(activeFeedback.id);
|
||||
} else {
|
||||
widget.collapse();
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal the feedback range in the editor
|
||||
const range = new Range(
|
||||
activeFeedback.range.startLineNumber, 1,
|
||||
activeFeedback.range.endLineNumber, 1
|
||||
);
|
||||
this._editor.revealRangeInCenterIfOutsideViewport(range, ScrollType.Smooth);
|
||||
}
|
||||
|
||||
private _clearWidgets(): void {
|
||||
for (const widget of this._widgets) {
|
||||
widget.dispose();
|
||||
}
|
||||
this._widgets.length = 0;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._clearWidgets();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(AgentFeedbackEditorWidgetContribution.ID, AgentFeedbackEditorWidgetContribution, EditorContributionInstantiation.Eventually);
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/agentFeedbackGlyphMargin.css';
|
||||
import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js';
|
||||
import { IEditorContribution } from '../../../../editor/common/editorCommon.js';
|
||||
import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js';
|
||||
import { IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js';
|
||||
import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js';
|
||||
import { Range } from '../../../../editor/common/core/range.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { IAgentFeedbackService } from './agentFeedbackService.js';
|
||||
import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
|
||||
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
|
||||
import { getSessionForResource } from './agentFeedbackEditorUtils.js';
|
||||
import { Selection } from '../../../../editor/common/core/selection.js';
|
||||
|
||||
const feedbackGlyphDecoration = ModelDecorationOptions.register({
|
||||
description: 'agent-feedback-glyph',
|
||||
linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-glyph`,
|
||||
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
|
||||
});
|
||||
|
||||
const addFeedbackHintDecoration = ModelDecorationOptions.register({
|
||||
description: 'agent-feedback-add-hint',
|
||||
linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`,
|
||||
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
|
||||
});
|
||||
|
||||
export class AgentFeedbackGlyphMarginContribution extends Disposable implements IEditorContribution {
|
||||
|
||||
static readonly ID = 'agentFeedback.glyphMarginContribution';
|
||||
|
||||
private readonly _feedbackDecorations;
|
||||
|
||||
private _hintDecorationId: string | null = null;
|
||||
private _hintLine = -1;
|
||||
private _sessionResource: URI | undefined;
|
||||
private _feedbackLines = new Set<number>();
|
||||
|
||||
constructor(
|
||||
private readonly _editor: ICodeEditor,
|
||||
@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,
|
||||
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
|
||||
@IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._feedbackDecorations = this._editor.createDecorationsCollection();
|
||||
|
||||
this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackDecorations()));
|
||||
this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged()));
|
||||
this._store.add(this._editor.onMouseMove((e: IEditorMouseEvent) => this._onMouseMove(e)));
|
||||
this._store.add(this._editor.onMouseLeave(() => this._updateHintDecoration(-1)));
|
||||
this._store.add(this._editor.onMouseDown((e: IEditorMouseEvent) => this._onMouseDown(e)));
|
||||
|
||||
this._resolveSession();
|
||||
this._updateFeedbackDecorations();
|
||||
}
|
||||
|
||||
private _onModelChanged(): void {
|
||||
this._updateHintDecoration(-1);
|
||||
this._resolveSession();
|
||||
this._updateFeedbackDecorations();
|
||||
}
|
||||
|
||||
private _resolveSession(): void {
|
||||
const model = this._editor.getModel();
|
||||
if (!model) {
|
||||
this._sessionResource = undefined;
|
||||
return;
|
||||
}
|
||||
this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService);
|
||||
}
|
||||
|
||||
private _updateFeedbackDecorations(): void {
|
||||
if (!this._sessionResource) {
|
||||
this._feedbackDecorations.clear();
|
||||
this._feedbackLines.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const feedbackItems = this._agentFeedbackService.getFeedback(this._sessionResource);
|
||||
const decorations: IModelDeltaDecoration[] = [];
|
||||
const lines = new Set<number>();
|
||||
|
||||
for (const item of feedbackItems) {
|
||||
const model = this._editor.getModel();
|
||||
if (!model || item.resourceUri.toString() !== model.uri.toString()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = item.range.startLineNumber;
|
||||
lines.add(line);
|
||||
decorations.push({
|
||||
range: new Range(line, 1, line, 1),
|
||||
options: feedbackGlyphDecoration,
|
||||
});
|
||||
}
|
||||
|
||||
this._feedbackLines = lines;
|
||||
this._feedbackDecorations.set(decorations);
|
||||
}
|
||||
|
||||
private _onMouseMove(e: IEditorMouseEvent): void {
|
||||
if (!this._sessionResource) {
|
||||
this._updateHintDecoration(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines;
|
||||
const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY;
|
||||
if (e.target.position
|
||||
&& (isLineDecoration || isContentArea)
|
||||
&& !this._feedbackLines.has(e.target.position.lineNumber)
|
||||
) {
|
||||
this._updateHintDecoration(e.target.position.lineNumber);
|
||||
} else {
|
||||
this._updateHintDecoration(-1);
|
||||
}
|
||||
}
|
||||
|
||||
private _updateHintDecoration(line: number): void {
|
||||
if (line === this._hintLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._hintLine = line;
|
||||
this._editor.changeDecorations(accessor => {
|
||||
if (this._hintDecorationId) {
|
||||
accessor.removeDecoration(this._hintDecorationId);
|
||||
this._hintDecorationId = null;
|
||||
}
|
||||
if (line !== -1) {
|
||||
this._hintDecorationId = accessor.addDecoration(
|
||||
new Range(line, 1, line, 1),
|
||||
addFeedbackHintDecoration,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _onMouseDown(e: IEditorMouseEvent): void {
|
||||
if (!e.target.position
|
||||
|| e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS
|
||||
|| e.target.detail.isAfterLines
|
||||
|| !this._sessionResource
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lineNumber = e.target.position.lineNumber;
|
||||
|
||||
// Lines with existing feedback - do nothing
|
||||
if (this._feedbackLines.has(lineNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Select the line content and focus the editor
|
||||
const model = this._editor.getModel();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startColumn = model.getLineFirstNonWhitespaceColumn(lineNumber);
|
||||
const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber);
|
||||
if (startColumn === 0 || endColumn === 0) {
|
||||
// Empty line - select the whole line range
|
||||
this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1));
|
||||
} else {
|
||||
this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn));
|
||||
}
|
||||
this._editor.focus();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._feedbackDecorations.clear();
|
||||
this._updateHintDecoration(-1);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(AgentFeedbackGlyphMarginContribution.ID, AgentFeedbackGlyphMarginContribution, EditorContributionInstantiation.Eventually);
|
||||
@@ -4,24 +4,198 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from '../../../../base/browser/dom.js';
|
||||
import { HoverStyle } from '../../../../base/browser/ui/hover/hover.js';
|
||||
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
|
||||
import { HoverStyle, IDelayedHoverOptions } from '../../../../base/browser/ui/hover/hover.js';
|
||||
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
|
||||
import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
|
||||
import { IObjectTreeElement, ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js';
|
||||
import { Action } from '../../../../base/common/actions.js';
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { basename } from '../../../../base/common/path.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { IRange } from '../../../../editor/common/core/range.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { FileKind } from '../../../../platform/files/common/files.js';
|
||||
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
|
||||
import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js';
|
||||
import { WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js';
|
||||
import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';
|
||||
import { IAgentFeedbackService } from './agentFeedbackService.js';
|
||||
import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
|
||||
import { editorHoverBackground } from '../../../../platform/theme/common/colorRegistry.js';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
// --- Tree Element Types ---
|
||||
|
||||
interface IFeedbackFileElement {
|
||||
readonly type: 'file';
|
||||
readonly uri: URI;
|
||||
readonly items: ReadonlyArray<IFeedbackCommentElement>;
|
||||
}
|
||||
|
||||
interface IFeedbackCommentElement {
|
||||
readonly type: 'comment';
|
||||
readonly id: string;
|
||||
readonly text: string;
|
||||
readonly resourceUri: URI;
|
||||
readonly range: IRange;
|
||||
}
|
||||
|
||||
type FeedbackTreeElement = IFeedbackFileElement | IFeedbackCommentElement;
|
||||
|
||||
function isFeedbackFileElement(element: FeedbackTreeElement): element is IFeedbackFileElement {
|
||||
return element.type === 'file';
|
||||
}
|
||||
|
||||
// --- Tree Delegate ---
|
||||
|
||||
class FeedbackTreeDelegate implements IListVirtualDelegate<FeedbackTreeElement> {
|
||||
getHeight(_element: FeedbackTreeElement): number {
|
||||
return 22;
|
||||
}
|
||||
|
||||
getTemplateId(element: FeedbackTreeElement): string {
|
||||
return isFeedbackFileElement(element)
|
||||
? FeedbackFileRenderer.TEMPLATE_ID
|
||||
: FeedbackCommentRenderer.TEMPLATE_ID;
|
||||
}
|
||||
}
|
||||
|
||||
// --- File Renderer ---
|
||||
|
||||
interface IFeedbackFileTemplate {
|
||||
readonly label: IResourceLabel;
|
||||
readonly actionBar: ActionBar;
|
||||
readonly templateDisposables: DisposableStore;
|
||||
}
|
||||
|
||||
class FeedbackFileRenderer implements ITreeRenderer<IFeedbackFileElement, void, IFeedbackFileTemplate> {
|
||||
static readonly TEMPLATE_ID = 'feedbackFile';
|
||||
readonly templateId = FeedbackFileRenderer.TEMPLATE_ID;
|
||||
|
||||
constructor(
|
||||
private readonly _labels: ResourceLabels,
|
||||
private readonly _agentFeedbackService: IAgentFeedbackService,
|
||||
private readonly _sessionResource: URI,
|
||||
) { }
|
||||
|
||||
renderTemplate(container: HTMLElement): IFeedbackFileTemplate {
|
||||
const templateDisposables = new DisposableStore();
|
||||
|
||||
const label = templateDisposables.add(this._labels.create(container, { supportHighlights: true, supportIcons: true }));
|
||||
|
||||
const actionBarContainer = $('div.agent-feedback-hover-action-bar');
|
||||
label.element.appendChild(actionBarContainer);
|
||||
const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));
|
||||
|
||||
return { label, actionBar, templateDisposables };
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<IFeedbackFileElement, void>, _index: number, templateData: IFeedbackFileTemplate): void {
|
||||
const element = node.element;
|
||||
templateData.label.element.style.display = 'flex';
|
||||
|
||||
const name = basename(element.uri.path);
|
||||
|
||||
|
||||
templateData.label.setResource(
|
||||
{ resource: element.uri, name },
|
||||
{ fileKind: FileKind.FILE },
|
||||
);
|
||||
|
||||
templateData.actionBar.clear();
|
||||
templateData.actionBar.push(new Action(
|
||||
'agentFeedback.removeFileComments',
|
||||
localize('agentFeedbackHover.removeAll', "Remove All"),
|
||||
ThemeIcon.asClassName(Codicon.close),
|
||||
true,
|
||||
() => {
|
||||
for (const item of element.items) {
|
||||
this._agentFeedbackService.removeFeedback(this._sessionResource, item.id);
|
||||
}
|
||||
}
|
||||
), { icon: true, label: false });
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IFeedbackFileTemplate): void {
|
||||
templateData.templateDisposables.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Comment Renderer ---
|
||||
|
||||
interface IFeedbackCommentTemplate {
|
||||
readonly textElement: HTMLElement;
|
||||
readonly actionBar: ActionBar;
|
||||
readonly templateDisposables: DisposableStore;
|
||||
element: IFeedbackCommentElement | undefined;
|
||||
}
|
||||
|
||||
class FeedbackCommentRenderer implements ITreeRenderer<IFeedbackCommentElement, void, IFeedbackCommentTemplate> {
|
||||
static readonly TEMPLATE_ID = 'feedbackComment';
|
||||
readonly templateId = FeedbackCommentRenderer.TEMPLATE_ID;
|
||||
|
||||
constructor(
|
||||
private readonly _agentFeedbackService: IAgentFeedbackService,
|
||||
private readonly _sessionResource: URI,
|
||||
) { }
|
||||
|
||||
renderTemplate(container: HTMLElement): IFeedbackCommentTemplate {
|
||||
const templateDisposables = new DisposableStore();
|
||||
|
||||
const row = dom.append(container, $('div.agent-feedback-hover-comment-row'));
|
||||
|
||||
const textElement = dom.append(row, $('div.agent-feedback-hover-comment-text'));
|
||||
|
||||
const actionBarContainer = dom.append(row, $('div.agent-feedback-hover-action-bar'));
|
||||
const actionBar = templateDisposables.add(new ActionBar(actionBarContainer));
|
||||
|
||||
const templateData: IFeedbackCommentTemplate = { textElement, actionBar, templateDisposables, element: undefined };
|
||||
|
||||
templateDisposables.add(dom.addDisposableListener(row, dom.EventType.CLICK, (e) => {
|
||||
const data = templateData.element;
|
||||
if (data) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._agentFeedbackService.revealFeedback(this._sessionResource, data.id);
|
||||
}
|
||||
}));
|
||||
|
||||
return templateData;
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<IFeedbackCommentElement, void>, _index: number, templateData: IFeedbackCommentTemplate): void {
|
||||
const element = node.element;
|
||||
|
||||
templateData.textElement.textContent = element.text;
|
||||
templateData.element = element;
|
||||
|
||||
templateData.actionBar.clear();
|
||||
templateData.actionBar.push(new Action(
|
||||
'agentFeedback.removeComment',
|
||||
localize('agentFeedbackHover.remove', "Remove"),
|
||||
ThemeIcon.asClassName(Codicon.close),
|
||||
true,
|
||||
() => {
|
||||
this._agentFeedbackService.removeFeedback(this._sessionResource, element.id);
|
||||
}
|
||||
), { icon: true, label: false });
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IFeedbackCommentTemplate): void {
|
||||
templateData.templateDisposables.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hover ---
|
||||
|
||||
/**
|
||||
* Creates the custom hover content for the "N comments" attachment.
|
||||
* Shows each feedback item with its file, range, text, and actions (remove / go to).
|
||||
* Uses a WorkbenchObjectTree to render files as parent nodes and comments as children,
|
||||
* with per-row action bars for removal.
|
||||
*/
|
||||
export class AgentFeedbackHover extends Disposable {
|
||||
|
||||
@@ -30,7 +204,6 @@ export class AgentFeedbackHover extends Disposable {
|
||||
private readonly _attachment: IAgentFeedbackVariableEntry,
|
||||
@IHoverService private readonly _hoverService: IHoverService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,
|
||||
) {
|
||||
super();
|
||||
@@ -38,7 +211,7 @@ export class AgentFeedbackHover extends Disposable {
|
||||
// Show on hover (delayed)
|
||||
this._store.add(this._hoverService.setupDelayedHover(
|
||||
this._element,
|
||||
() => this._buildHoverContent(),
|
||||
() => this._store.add(this._buildHoverContent()), // needs a better disposable story
|
||||
{ groupId: 'chat-attachments' }
|
||||
));
|
||||
|
||||
@@ -52,6 +225,7 @@ export class AgentFeedbackHover extends Disposable {
|
||||
|
||||
private _showHoverNow(): void {
|
||||
const opts = this._buildHoverContent();
|
||||
this._register(opts);
|
||||
this._hoverService.showInstantHover({
|
||||
content: opts.content,
|
||||
target: this._element,
|
||||
@@ -61,89 +235,126 @@ export class AgentFeedbackHover extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
private _buildHoverContent(): { content: HTMLElement; style: HoverStyle; position: { hoverPosition: HoverPosition }; trapFocus: boolean; dispose: () => void } {
|
||||
private _buildHoverContent(): IDelayedHoverOptions & IDisposable {
|
||||
const disposables = new DisposableStore();
|
||||
const hoverElement = dom.$('div.agent-feedback-hover');
|
||||
const hoverElement = $('div.agent-feedback-hover');
|
||||
|
||||
const title = dom.$('div.agent-feedback-hover-title');
|
||||
title.textContent = this._attachment.feedbackItems.length === 1
|
||||
? localize('agentFeedbackHover.titleOne', "1 feedback comment")
|
||||
: localize('agentFeedbackHover.titleMany', "{0} feedback comments", this._attachment.feedbackItems.length);
|
||||
hoverElement.appendChild(title);
|
||||
// Tree container
|
||||
const treeContainer = dom.append(hoverElement, $('.results.show-file-icons.file-icon-themable-tree.agent-feedback-hover-tree'));
|
||||
|
||||
const list = dom.$('div.agent-feedback-hover-list');
|
||||
hoverElement.appendChild(list);
|
||||
|
||||
// Create ResourceLabels for file icons
|
||||
// Resource labels (shared across all file renderers)
|
||||
const resourceLabels = disposables.add(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));
|
||||
|
||||
// Group feedback items by file
|
||||
const byFile = new Map<string, typeof this._attachment.feedbackItems[number][]>();
|
||||
for (const item of this._attachment.feedbackItems) {
|
||||
const key = item.resourceUri.toString();
|
||||
let group = byFile.get(key);
|
||||
if (!group) {
|
||||
group = [];
|
||||
byFile.set(key, group);
|
||||
// Build tree data
|
||||
const { children, commentElements } = this._buildTreeData();
|
||||
|
||||
// Create tree
|
||||
const tree = disposables.add(this._instantiationService.createInstance(
|
||||
WorkbenchObjectTree<FeedbackTreeElement>,
|
||||
'AgentFeedbackHoverTree',
|
||||
treeContainer,
|
||||
new FeedbackTreeDelegate(),
|
||||
[
|
||||
new FeedbackFileRenderer(resourceLabels, this._agentFeedbackService, this._attachment.sessionResource),
|
||||
new FeedbackCommentRenderer(this._agentFeedbackService, this._attachment.sessionResource),
|
||||
],
|
||||
{
|
||||
defaultIndent: 0,
|
||||
alwaysConsumeMouseWheel: false,
|
||||
accessibilityProvider: {
|
||||
getAriaLabel: (element: FeedbackTreeElement) => {
|
||||
if (isFeedbackFileElement(element)) {
|
||||
return basename(element.uri.path);
|
||||
}
|
||||
return element.text;
|
||||
},
|
||||
getWidgetAriaLabel: () => localize('agentFeedbackHover.tree', "Feedback Comments"),
|
||||
},
|
||||
identityProvider: {
|
||||
getId: (element: FeedbackTreeElement) => {
|
||||
if (isFeedbackFileElement(element)) {
|
||||
return `file:${element.uri.toString()}`;
|
||||
}
|
||||
return `comment:${element.id}`;
|
||||
}
|
||||
},
|
||||
overrideStyles: {
|
||||
listFocusBackground: undefined,
|
||||
listInactiveFocusBackground: undefined,
|
||||
listActiveSelectionBackground: undefined,
|
||||
listFocusAndSelectionBackground: undefined,
|
||||
listInactiveSelectionBackground: undefined,
|
||||
listBackground: editorHoverBackground,
|
||||
listFocusForeground: undefined,
|
||||
treeStickyScrollBackground: editorHoverBackground,
|
||||
}
|
||||
}
|
||||
group.push(item);
|
||||
}
|
||||
));
|
||||
|
||||
for (const [, items] of byFile) {
|
||||
// File header with icon via ResourceLabels
|
||||
const fileHeader = dom.$('div.agent-feedback-hover-file-header');
|
||||
list.appendChild(fileHeader);
|
||||
const label = resourceLabels.create(fileHeader);
|
||||
label.setFile(items[0].resourceUri, { hidePath: false });
|
||||
// Set tree data
|
||||
tree.setChildren(null, children);
|
||||
|
||||
for (const item of items) {
|
||||
const row = dom.$('div.agent-feedback-hover-row');
|
||||
list.appendChild(row);
|
||||
|
||||
// Feedback text - clicking goes to location
|
||||
const text = dom.$('div.agent-feedback-hover-text');
|
||||
text.textContent = item.text;
|
||||
row.appendChild(text);
|
||||
|
||||
row.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._goToFeedback(item.resourceUri, item.range);
|
||||
});
|
||||
|
||||
// Remove button
|
||||
const removeBtn = dom.$('a.agent-feedback-hover-remove');
|
||||
removeBtn.title = localize('agentFeedbackHover.remove', "Remove feedback");
|
||||
const removeIcon = dom.$('span');
|
||||
removeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close));
|
||||
removeBtn.appendChild(removeIcon);
|
||||
row.appendChild(removeBtn);
|
||||
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._agentFeedbackService.removeFeedback(this._attachment.sessionResource, item.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Layout tree: clamp to reasonable height
|
||||
const ROW_HEIGHT = 22;
|
||||
const MAX_ROWS = 8;
|
||||
const totalRows = commentElements.length + children.length;
|
||||
const treeHeight = Math.min(totalRows * ROW_HEIGHT, MAX_ROWS * ROW_HEIGHT);
|
||||
tree.layout(treeHeight, 200);
|
||||
treeContainer.style.height = `${treeHeight}px`;
|
||||
|
||||
return {
|
||||
content: hoverElement,
|
||||
style: HoverStyle.Pointer,
|
||||
position: { hoverPosition: HoverPosition.BELOW },
|
||||
position: { hoverPosition: HoverPosition.ABOVE },
|
||||
trapFocus: true,
|
||||
appearance: { compact: true },
|
||||
additionalClasses: ['agent-feedback-hover-container'],
|
||||
dispose: () => disposables.dispose(),
|
||||
};
|
||||
}
|
||||
|
||||
private _goToFeedback(resourceUri: URI, range: IRange): void {
|
||||
this._editorService.openEditor({
|
||||
resource: resourceUri,
|
||||
options: {
|
||||
selection: range,
|
||||
preserveFocus: false,
|
||||
revealIfVisible: true,
|
||||
private _buildTreeData(): { children: IObjectTreeElement<FeedbackTreeElement>[]; commentElements: IFeedbackCommentElement[] } {
|
||||
// Group feedback items by file
|
||||
const byFile = new Map<string, { uri: URI; comments: IFeedbackCommentElement[] }>();
|
||||
for (const item of this._attachment.feedbackItems) {
|
||||
const key = item.resourceUri.toString();
|
||||
let group = byFile.get(key);
|
||||
if (!group) {
|
||||
group = { uri: item.resourceUri, comments: [] };
|
||||
byFile.set(key, group);
|
||||
}
|
||||
});
|
||||
group.comments.push({
|
||||
type: 'comment',
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
resourceUri: item.resourceUri,
|
||||
range: item.range,
|
||||
});
|
||||
}
|
||||
|
||||
const children: IObjectTreeElement<FeedbackTreeElement>[] = [];
|
||||
const allComments: IFeedbackCommentElement[] = [];
|
||||
|
||||
for (const [, group] of byFile) {
|
||||
const fileElement: IFeedbackFileElement = {
|
||||
type: 'file',
|
||||
uri: group.uri,
|
||||
items: group.comments,
|
||||
};
|
||||
|
||||
allComments.push(...group.comments);
|
||||
|
||||
children.push({
|
||||
element: fileElement,
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
children: group.comments.map(comment => ({
|
||||
element: comment,
|
||||
collapsible: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { children, commentElements: allComments };
|
||||
}
|
||||
}
|
||||
|
||||
+6
-25
@@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js';
|
||||
import { IEditorContribution } from '../../../../editor/common/editorCommon.js';
|
||||
import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js';
|
||||
import { IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js';
|
||||
import { TrackedRangeStickiness } from '../../../../editor/common/model.js';
|
||||
import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js';
|
||||
import { Range } from '../../../../editor/common/core/range.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
@@ -20,12 +20,6 @@ import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browse
|
||||
import { getSessionForResource } from './agentFeedbackEditorUtils.js';
|
||||
import { Selection } from '../../../../editor/common/core/selection.js';
|
||||
|
||||
const feedbackLineDecoration = ModelDecorationOptions.register({
|
||||
description: 'agent-feedback-line-decoration',
|
||||
linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-line-decoration`,
|
||||
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
|
||||
});
|
||||
|
||||
const addFeedbackHintDecoration = ModelDecorationOptions.register({
|
||||
description: 'agent-feedback-add-hint',
|
||||
linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`,
|
||||
@@ -36,8 +30,6 @@ export class AgentFeedbackLineDecorationContribution extends Disposable implemen
|
||||
|
||||
static readonly ID = 'agentFeedback.lineDecorationContribution';
|
||||
|
||||
private readonly _feedbackDecorations;
|
||||
|
||||
private _hintDecorationId: string | null = null;
|
||||
private _hintLine = -1;
|
||||
private _sessionResource: URI | undefined;
|
||||
@@ -51,22 +43,20 @@ export class AgentFeedbackLineDecorationContribution extends Disposable implemen
|
||||
) {
|
||||
super();
|
||||
|
||||
this._feedbackDecorations = this._editor.createDecorationsCollection();
|
||||
|
||||
this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackDecorations()));
|
||||
this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackLines()));
|
||||
this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged()));
|
||||
this._store.add(this._editor.onMouseMove((e: IEditorMouseEvent) => this._onMouseMove(e)));
|
||||
this._store.add(this._editor.onMouseLeave(() => this._updateHintDecoration(-1)));
|
||||
this._store.add(this._editor.onMouseDown((e: IEditorMouseEvent) => this._onMouseDown(e)));
|
||||
|
||||
this._resolveSession();
|
||||
this._updateFeedbackDecorations();
|
||||
this._updateFeedbackLines();
|
||||
}
|
||||
|
||||
private _onModelChanged(): void {
|
||||
this._updateHintDecoration(-1);
|
||||
this._resolveSession();
|
||||
this._updateFeedbackDecorations();
|
||||
this._updateFeedbackLines();
|
||||
}
|
||||
|
||||
private _resolveSession(): void {
|
||||
@@ -78,15 +68,13 @@ export class AgentFeedbackLineDecorationContribution extends Disposable implemen
|
||||
this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService);
|
||||
}
|
||||
|
||||
private _updateFeedbackDecorations(): void {
|
||||
private _updateFeedbackLines(): void {
|
||||
if (!this._sessionResource) {
|
||||
this._feedbackDecorations.clear();
|
||||
this._feedbackLines.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const feedbackItems = this._agentFeedbackService.getFeedback(this._sessionResource);
|
||||
const decorations: IModelDeltaDecoration[] = [];
|
||||
const lines = new Set<number>();
|
||||
|
||||
for (const item of feedbackItems) {
|
||||
@@ -95,16 +83,10 @@ export class AgentFeedbackLineDecorationContribution extends Disposable implemen
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = item.range.startLineNumber;
|
||||
lines.add(line);
|
||||
decorations.push({
|
||||
range: new Range(line, 1, line, 1),
|
||||
options: feedbackLineDecoration,
|
||||
});
|
||||
lines.add(item.range.startLineNumber);
|
||||
}
|
||||
|
||||
this._feedbackLines = lines;
|
||||
this._feedbackDecorations.set(decorations);
|
||||
}
|
||||
|
||||
private _onMouseMove(e: IEditorMouseEvent): void {
|
||||
@@ -179,7 +161,6 @@ export class AgentFeedbackLineDecorationContribution extends Disposable implemen
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._feedbackDecorations.clear();
|
||||
this._updateHintDecoration(-1);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isEqual } from '../../../../base/common/resources.js';
|
||||
import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
|
||||
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
|
||||
import { agentSessionContainsResource, editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js';
|
||||
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
|
||||
|
||||
// --- Types --------------------------------------------------------------------
|
||||
|
||||
@@ -64,6 +65,11 @@ export interface IAgentFeedbackService {
|
||||
*/
|
||||
getMostRecentSessionForResource(resourceUri: URI): URI | undefined;
|
||||
|
||||
/**
|
||||
* Set the navigation anchor to a specific feedback item, open its editor, and fire a navigation event.
|
||||
*/
|
||||
revealFeedback(sessionResource: URI, feedbackId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Navigate to next/previous feedback item in a session.
|
||||
*/
|
||||
@@ -100,6 +106,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe
|
||||
constructor(
|
||||
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
|
||||
@IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -119,7 +126,35 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe
|
||||
range,
|
||||
sessionResource,
|
||||
};
|
||||
feedbackItems.push(feedback);
|
||||
|
||||
// Insert at the correct sorted position.
|
||||
// Files are grouped by recency: first feedback for a new file appears after
|
||||
// all existing files. Within a file, items are sorted by startLineNumber.
|
||||
const resourceStr = resourceUri.toString();
|
||||
const hasExistingForFile = feedbackItems.some(f => f.resourceUri.toString() === resourceStr);
|
||||
|
||||
if (!hasExistingForFile) {
|
||||
// New file — append at the end
|
||||
feedbackItems.push(feedback);
|
||||
} else {
|
||||
// Find insertion point: after the last item for a different file that
|
||||
// precedes this file's block, then within this file's block by line number.
|
||||
let insertIdx = feedbackItems.length;
|
||||
for (let i = 0; i < feedbackItems.length; i++) {
|
||||
if (feedbackItems[i].resourceUri.toString() === resourceStr
|
||||
&& feedbackItems[i].range.startLineNumber > range.startLineNumber) {
|
||||
insertIdx = i;
|
||||
break;
|
||||
}
|
||||
// If we passed the last item for this file without finding a larger
|
||||
// line number, insert right after the file's block.
|
||||
if (feedbackItems[i].resourceUri.toString() === resourceStr) {
|
||||
insertIdx = i + 1;
|
||||
}
|
||||
}
|
||||
feedbackItems.splice(insertIdx, 0, feedback);
|
||||
}
|
||||
|
||||
this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);
|
||||
this._onDidChangeNavigation.fire(sessionResource);
|
||||
|
||||
@@ -208,6 +243,24 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe
|
||||
return false;
|
||||
}
|
||||
|
||||
async revealFeedback(sessionResource: URI, feedbackId: string): Promise<void> {
|
||||
const key = sessionResource.toString();
|
||||
const feedbackItems = this._feedbackBySession.get(key);
|
||||
const feedback = feedbackItems?.find(f => f.id === feedbackId);
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
await this._editorService.openEditor({
|
||||
resource: feedback.resourceUri,
|
||||
options: {
|
||||
preserveFocus: false,
|
||||
revealIfVisible: true,
|
||||
}
|
||||
});
|
||||
this._navigationAnchorBySession.set(key, feedbackId);
|
||||
this._onDidChangeNavigation.fire(sessionResource);
|
||||
}
|
||||
|
||||
getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined {
|
||||
const key = sessionResource.toString();
|
||||
const feedbackItems = this._feedbackBySession.get(key);
|
||||
|
||||
@@ -4,83 +4,44 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.agent-feedback-hover {
|
||||
max-width: 400px;
|
||||
padding: 4px 0;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-title {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px 6px;
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
||||
margin-bottom: 4px;
|
||||
.agent-feedback-hover-container .hover-contents {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
/* Tree container */
|
||||
.agent-feedback-hover-tree {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-file-header {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: var(--vscode-foreground);
|
||||
padding: 6px 8px 2px;
|
||||
font-family: var(--monaco-monospace-font);
|
||||
}
|
||||
|
||||
.agent-feedback-hover-file-header:not(:first-child) {
|
||||
border-top: 1px solid var(--vscode-editorWidget-border);
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-row {
|
||||
padding: 4px 8px;
|
||||
/* Comment row inside tree */
|
||||
.agent-feedback-hover-comment-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-row:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.agent-feedback-hover-line {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.agent-feedback-hover-text {
|
||||
.agent-feedback-hover-comment-text {
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
opacity: 0;
|
||||
/* Action bar: hidden by default, shown on row hover */
|
||||
.agent-feedback-hover-action-bar {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-row:hover .agent-feedback-hover-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-feedback-hover-remove:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
.agent-feedback-hover-tree .monaco-list-row:hover .agent-feedback-hover-action-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Attachment widget pill styling */
|
||||
|
||||
@@ -5,28 +5,44 @@
|
||||
|
||||
.agent-feedback-input-widget {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder));
|
||||
z-index: 10000;
|
||||
background-color: var(--vscode-panel-background);
|
||||
border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border));
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow);
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.agent-feedback-input-widget input {
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
.agent-feedback-input-widget textarea {
|
||||
background-color: var(--vscode-panel-background);
|
||||
border: none;
|
||||
color: var(--vscode-input-foreground);
|
||||
border-radius: 2px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
width: 240px;
|
||||
min-width: 150px;
|
||||
max-width: 400px;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-feedback-input-widget input:focus {
|
||||
.agent-feedback-input-widget textarea:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.agent-feedback-input-widget input::placeholder {
|
||||
.agent-feedback-input-widget textarea::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
.agent-feedback-input-widget .agent-feedback-input-measure {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/* Main widget container - speech bubble style */
|
||||
.agent-feedback-widget {
|
||||
position: absolute;
|
||||
max-width: 280px;
|
||||
min-width: 180px;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.agent-feedback-widget.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-feedback-widget.fadeOut {
|
||||
animation: agentFeedbackFadeOut 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes agentFeedbackFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Arrow pointer pointing left toward the code */
|
||||
.agent-feedback-widget-arrow {
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-right: 8px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder));
|
||||
}
|
||||
|
||||
.agent-feedback-widget.collapsed .agent-feedback-widget-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-feedback-widget-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: -7px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 7px solid transparent;
|
||||
border-bottom: 7px solid transparent;
|
||||
border-right: 7px solid var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.agent-feedback-widget-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border));
|
||||
border-radius: 8px 8px 0 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.agent-feedback-widget.collapsed .agent-feedback-widget-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-feedback-widget-header:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.agent-feedback-widget-title {
|
||||
font-weight: 500;
|
||||
color: var(--vscode-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Spacer to push buttons to the right */
|
||||
.agent-feedback-widget-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Toggle button */
|
||||
.agent-feedback-widget-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--vscode-foreground);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.agent-feedback-widget-toggle:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
/* Dismiss button */
|
||||
.agent-feedback-widget-dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--vscode-foreground);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.agent-feedback-widget-dismiss:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
/* Body - collapsible */
|
||||
.agent-feedback-widget-body {
|
||||
transition: max-height 0.2s ease-in-out, padding 0.2s ease-in-out;
|
||||
border-radius: 0 0 8px 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-feedback-widget-body.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Individual feedback item */
|
||||
.agent-feedback-widget-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border));
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-feedback-widget-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-feedback-widget-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.agent-feedback-widget-item.focused {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
/* Line info */
|
||||
.agent-feedback-widget-line-info {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Feedback text */
|
||||
.agent-feedback-widget-text {
|
||||
color: var(--vscode-foreground);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Gutter decoration for range indicator on hover */
|
||||
.agent-feedback-widget-range-glyph {
|
||||
margin-left: 8px;
|
||||
z-index: 5;
|
||||
border-left: 2px solid var(--vscode-editorGutter-modifiedBackground);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.monaco-editor .agent-feedback-glyph,
|
||||
.monaco-editor .agent-feedback-add-hint {
|
||||
border-radius: 3px;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.monaco-editor .agent-feedback-glyph {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.monaco-editor .agent-feedback-add-hint {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.monaco-editor .agent-feedback-add-hint:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -9,14 +9,18 @@
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--vscode-editorHoverWidget-background);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vscode-editorHoverWidget-border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.monaco-editor .agent-feedback-line-decoration {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
.monaco-editor .agent-feedback-line-decoration:hover,
|
||||
.monaco-editor .agent-feedback-add-hint:hover {
|
||||
background-color: var(--vscode-editorHoverWidget-border);
|
||||
}
|
||||
|
||||
.monaco-editor .agent-feedback-add-hint {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { Range } from '../../../../../editor/common/core/range.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { mock } from '../../../../../base/test/common/mock.js';
|
||||
import { AgentFeedbackService, IAgentFeedbackService } from '../../browser/agentFeedbackService.js';
|
||||
import { IChatEditingService } from '../../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
|
||||
import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
|
||||
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
|
||||
|
||||
function r(startLine: number, endLine: number = startLine): Range {
|
||||
return new Range(startLine, 1, endLine, 1);
|
||||
}
|
||||
|
||||
function feedbackSummary(items: readonly { resourceUri: URI; range: { startLineNumber: number } }[]): string[] {
|
||||
return items.map(f => `${f.resourceUri.path}:${f.range.startLineNumber}`);
|
||||
}
|
||||
|
||||
suite('AgentFeedbackService - Ordering', () => {
|
||||
|
||||
const store = new DisposableStore();
|
||||
let service: IAgentFeedbackService;
|
||||
let session: URI;
|
||||
let fileA: URI;
|
||||
let fileB: URI;
|
||||
let fileC: URI;
|
||||
|
||||
setup(() => {
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
|
||||
instantiationService.stub(IChatEditingService, new class extends mock<IChatEditingService>() { });
|
||||
instantiationService.stub(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { });
|
||||
|
||||
service = store.add(instantiationService.createInstance(AgentFeedbackService));
|
||||
session = URI.parse('test://session/1');
|
||||
fileA = URI.parse('file:///a.ts');
|
||||
fileB = URI.parse('file:///b.ts');
|
||||
fileC = URI.parse('file:///c.ts');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
store.clear();
|
||||
});
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('single file - items sorted by line number', () => {
|
||||
service.addFeedback(session, fileA, r(20), 'line 20');
|
||||
service.addFeedback(session, fileA, r(5), 'line 5');
|
||||
service.addFeedback(session, fileA, r(10), 'line 10');
|
||||
|
||||
assert.deepStrictEqual(feedbackSummary(service.getFeedback(session)), [
|
||||
'/a.ts:5',
|
||||
'/a.ts:10',
|
||||
'/a.ts:20',
|
||||
]);
|
||||
});
|
||||
|
||||
test('multiple files - files ordered by recency, items within file sorted by line', () => {
|
||||
service.addFeedback(session, fileA, r(10), 'A:10');
|
||||
service.addFeedback(session, fileA, r(5), 'A:5');
|
||||
service.addFeedback(session, fileB, r(20), 'B:20');
|
||||
service.addFeedback(session, fileB, r(3), 'B:3');
|
||||
|
||||
assert.deepStrictEqual(feedbackSummary(service.getFeedback(session)), [
|
||||
'/a.ts:5',
|
||||
'/a.ts:10',
|
||||
'/b.ts:3',
|
||||
'/b.ts:20',
|
||||
]);
|
||||
});
|
||||
|
||||
test('new file appended to end', () => {
|
||||
service.addFeedback(session, fileA, r(1), 'A:1');
|
||||
service.addFeedback(session, fileB, r(1), 'B:1');
|
||||
service.addFeedback(session, fileC, r(1), 'C:1');
|
||||
|
||||
assert.deepStrictEqual(feedbackSummary(service.getFeedback(session)), [
|
||||
'/a.ts:1',
|
||||
'/b.ts:1',
|
||||
'/c.ts:1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('adding to existing file does not change file ordering', () => {
|
||||
service.addFeedback(session, fileA, r(10), 'A:10');
|
||||
service.addFeedback(session, fileB, r(10), 'B:10');
|
||||
// Add more feedback to fileA — should stay before fileB
|
||||
service.addFeedback(session, fileA, r(5), 'A:5');
|
||||
service.addFeedback(session, fileA, r(20), 'A:20');
|
||||
|
||||
assert.deepStrictEqual(feedbackSummary(service.getFeedback(session)), [
|
||||
'/a.ts:5',
|
||||
'/a.ts:10',
|
||||
'/a.ts:20',
|
||||
'/b.ts:10',
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleaved adds across files maintain file recency and line sort', () => {
|
||||
service.addFeedback(session, fileA, r(30), 'A:30');
|
||||
service.addFeedback(session, fileB, r(50), 'B:50');
|
||||
service.addFeedback(session, fileA, r(10), 'A:10');
|
||||
service.addFeedback(session, fileC, r(1), 'C:1');
|
||||
service.addFeedback(session, fileB, r(5), 'B:5');
|
||||
service.addFeedback(session, fileA, r(20), 'A:20');
|
||||
|
||||
assert.deepStrictEqual(feedbackSummary(service.getFeedback(session)), [
|
||||
'/a.ts:10',
|
||||
'/a.ts:20',
|
||||
'/a.ts:30',
|
||||
'/b.ts:5',
|
||||
'/b.ts:50',
|
||||
'/c.ts:1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('navigation follows sorted order', () => {
|
||||
service.addFeedback(session, fileA, r(20), 'A:20');
|
||||
service.addFeedback(session, fileB, r(10), 'B:10');
|
||||
service.addFeedback(session, fileA, r(5), 'A:5');
|
||||
|
||||
// Expected order: A:5, A:20, B:10
|
||||
const first = service.getNextFeedback(session, true)!;
|
||||
assert.strictEqual(first.resourceUri.path, '/a.ts');
|
||||
assert.strictEqual(first.range.startLineNumber, 5);
|
||||
|
||||
const second = service.getNextFeedback(session, true)!;
|
||||
assert.strictEqual(second.resourceUri.path, '/a.ts');
|
||||
assert.strictEqual(second.range.startLineNumber, 20);
|
||||
|
||||
const third = service.getNextFeedback(session, true)!;
|
||||
assert.strictEqual(third.resourceUri.path, '/b.ts');
|
||||
assert.strictEqual(third.range.startLineNumber, 10);
|
||||
|
||||
// Wraps around
|
||||
const fourth = service.getNextFeedback(session, true)!;
|
||||
assert.strictEqual(fourth.resourceUri.path, '/a.ts');
|
||||
assert.strictEqual(fourth.range.startLineNumber, 5);
|
||||
});
|
||||
|
||||
test('navigation bearings reflect sorted position', () => {
|
||||
service.addFeedback(session, fileA, r(20), 'A:20');
|
||||
service.addFeedback(session, fileA, r(5), 'A:5');
|
||||
service.addFeedback(session, fileB, r(1), 'B:1');
|
||||
|
||||
// Before navigation, no anchor
|
||||
let bearing = service.getNavigationBearing(session);
|
||||
assert.strictEqual(bearing.activeIdx, -1);
|
||||
assert.strictEqual(bearing.totalCount, 3);
|
||||
|
||||
// Navigate to first (A:5)
|
||||
service.getNextFeedback(session, true);
|
||||
bearing = service.getNavigationBearing(session);
|
||||
assert.strictEqual(bearing.activeIdx, 0);
|
||||
|
||||
// Navigate to second (A:20)
|
||||
service.getNextFeedback(session, true);
|
||||
bearing = service.getNavigationBearing(session);
|
||||
assert.strictEqual(bearing.activeIdx, 1);
|
||||
|
||||
// Navigate to third (B:1)
|
||||
service.getNextFeedback(session, true);
|
||||
bearing = service.getNavigationBearing(session);
|
||||
assert.strictEqual(bearing.activeIdx, 2);
|
||||
});
|
||||
|
||||
test('removing feedback preserves ordering', () => {
|
||||
const f1 = service.addFeedback(session, fileA, r(30), 'A:30');
|
||||
service.addFeedback(session, fileA, r(10), 'A:10');
|
||||
service.addFeedback(session, fileA, r(20), 'A:20');
|
||||
|
||||
assert.deepStrictEqual(feedbackSummary(service.getFeedback(session)), [
|
||||
'/a.ts:10',
|
||||
'/a.ts:20',
|
||||
'/a.ts:30',
|
||||
]);
|
||||
|
||||
service.removeFeedback(session, f1.id);
|
||||
assert.deepStrictEqual(feedbackSummary(service.getFeedback(session)), [
|
||||
'/a.ts:10',
|
||||
'/a.ts:20',
|
||||
]);
|
||||
});
|
||||
|
||||
test('same line number items are stable', () => {
|
||||
const f1 = service.addFeedback(session, fileA, r(10), 'first');
|
||||
const f2 = service.addFeedback(session, fileA, r(10), 'second');
|
||||
|
||||
const items = service.getFeedback(session);
|
||||
assert.strictEqual(items[0].id, f1.id);
|
||||
assert.strictEqual(items[1].id, f2.id);
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,6 @@ import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browse
|
||||
import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
|
||||
import { ISessionsManagementService, IsNewChatSessionContext } from '../../sessions/browser/sessionsManagementService.js';
|
||||
import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js';
|
||||
import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js';
|
||||
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
|
||||
import { Menus } from '../../../browser/menus.js';
|
||||
import { BranchChatSessionAction } from './branchChatSessionAction.js';
|
||||
import { RunScriptContribution } from './runScriptAction.js';
|
||||
@@ -132,7 +130,6 @@ export class OpenSessionInTerminalAction extends Action2 {
|
||||
|
||||
override async run(accessor: ServicesAccessor,): Promise<void> {
|
||||
const terminalService = accessor.get(ITerminalService);
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const sessionsManagementService = accessor.get(ISessionsManagementService);
|
||||
|
||||
const activeSession = sessionsManagementService.activeSession.get();
|
||||
@@ -145,7 +142,7 @@ export class OpenSessionInTerminalAction extends Action2 {
|
||||
terminalService.setActiveInstance(instance);
|
||||
}
|
||||
}
|
||||
await viewsService.openView(TERMINAL_VIEW_ID, true);
|
||||
await terminalService.focusActiveInstance();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick
|
||||
import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js';
|
||||
import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js';
|
||||
import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
|
||||
import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js';
|
||||
import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js';
|
||||
import { Menus } from '../../../browser/menus.js';
|
||||
import { ISessionsConfigurationService, ISessionScript } from './sessionsConfigurationService.js';
|
||||
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js';
|
||||
|
||||
|
||||
|
||||
// Menu IDs - exported for use in auxiliary bar part
|
||||
export const RunScriptDropdownMenuId = MenuId.for('AgentSessionsRunScriptDropdown');
|
||||
|
||||
@@ -43,6 +44,9 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr
|
||||
|
||||
private readonly _activeRunState: IObservable<IRunScriptActionContext | undefined>;
|
||||
|
||||
/** Maps `cwd.toString() + '\n' + script.command` to the terminal instance for reuse. */
|
||||
private readonly _scriptTerminals = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@ITerminalService private readonly _terminalService: ITerminalService,
|
||||
@ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService,
|
||||
@@ -62,6 +66,15 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr
|
||||
return { session: activeSession, scripts, cwd };
|
||||
});
|
||||
|
||||
this._register(this._terminalService.onDidDisposeInstance(instance => {
|
||||
for (const [key, id] of this._scriptTerminals) {
|
||||
if (id === instance.instanceId) {
|
||||
this._scriptTerminals.delete(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this._registerActions();
|
||||
}
|
||||
|
||||
@@ -170,16 +183,48 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr
|
||||
}
|
||||
|
||||
private async _runScript(cwd: URI, script: ISessionScript): Promise<void> {
|
||||
const terminal = await this._terminalService.createTerminal({
|
||||
location: TerminalLocation.Panel,
|
||||
config: {
|
||||
name: script.name
|
||||
},
|
||||
cwd
|
||||
});
|
||||
const key = this._terminalKey(cwd, script);
|
||||
let terminal = this._getReusableTerminal(key);
|
||||
|
||||
terminal.sendText(script.command, true);
|
||||
await this._terminalService.revealTerminal(terminal);
|
||||
if (!terminal) {
|
||||
terminal = await this._terminalService.createTerminal({
|
||||
location: TerminalLocation.Panel,
|
||||
config: {
|
||||
name: script.name
|
||||
},
|
||||
cwd
|
||||
});
|
||||
this._scriptTerminals.set(key, terminal.instanceId);
|
||||
}
|
||||
|
||||
await terminal.sendText(script.command, true);
|
||||
this._terminalService.setActiveInstance(terminal);
|
||||
await this._terminalService.revealActiveTerminal();
|
||||
}
|
||||
|
||||
private _terminalKey(cwd: URI, script: ISessionScript): string {
|
||||
return `${cwd.toString()}\n${script.command}`;
|
||||
}
|
||||
|
||||
private _getReusableTerminal(key: string): ITerminalInstance | undefined {
|
||||
const instanceId = this._scriptTerminals.get(key);
|
||||
if (instanceId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const instance = this._terminalService.getInstanceFromId(instanceId);
|
||||
if (!instance || instance.isDisposed || instance.exitCode !== undefined) {
|
||||
this._scriptTerminals.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Only reuse if the cwd hasn't changed from the initial cwd and nothing is actively running
|
||||
if (instance.cwd !== instance.initialCwd || instance.hasChildProcesses) {
|
||||
this._scriptTerminals.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import * as event from '../../../../../base/common/event.js';
|
||||
import { IDisposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js';
|
||||
|
||||
/**
|
||||
@@ -19,10 +19,8 @@ export interface IChatAttachmentWidgetInstance extends IDisposable {
|
||||
|
||||
/**
|
||||
* Factory function type for creating attachment widgets.
|
||||
* Receives the instantiation service so it can create DI-injected widget instances.
|
||||
*/
|
||||
export type ChatAttachmentWidgetFactory = (
|
||||
instantiationService: IInstantiationService,
|
||||
attachment: IChatRequestVariableEntry,
|
||||
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
|
||||
container: HTMLElement,
|
||||
@@ -43,7 +41,6 @@ export interface IChatAttachmentWidgetRegistry {
|
||||
* Returns undefined if no factory is registered for the attachment's kind.
|
||||
*/
|
||||
createWidget(
|
||||
instantiationService: IInstantiationService,
|
||||
attachment: IChatRequestVariableEntry,
|
||||
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
|
||||
container: HTMLElement,
|
||||
@@ -68,7 +65,6 @@ export class ChatAttachmentWidgetRegistry implements IChatAttachmentWidgetRegist
|
||||
}
|
||||
|
||||
createWidget(
|
||||
instantiationService: IInstantiationService,
|
||||
attachment: IChatRequestVariableEntry,
|
||||
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
|
||||
container: HTMLElement,
|
||||
@@ -77,6 +73,6 @@ export class ChatAttachmentWidgetRegistry implements IChatAttachmentWidgetRegist
|
||||
if (!factory) {
|
||||
return undefined;
|
||||
}
|
||||
return factory(instantiationService, attachment, options, container);
|
||||
return factory(attachment, options, container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2454,7 +2454,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
|
||||
} else if (isSCMHistoryItemChangeRangeVariableEntry(attachment)) {
|
||||
attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemChangeRangeAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels);
|
||||
} else {
|
||||
attachmentWidget = this._chatAttachmentWidgetRegistry.createWidget(this.instantiationService, attachment, options, container)
|
||||
attachmentWidget = this._chatAttachmentWidgetRegistry.createWidget(attachment, options, container)
|
||||
?? this.instantiationService.createInstance(DefaultChatAttachmentWidget, resource, range, attachment, undefined, lm, options, container, this._contextResourceLabels);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user