Merge pull request #296555 from microsoft/benibenj/fresh-chinchilla

sessions feedback improvements
This commit is contained in:
Benjamin Christopher Simmonds
2026-02-21 10:01:18 +01:00
committed by GitHub
21 changed files with 1483 additions and 426 deletions
+13 -5
View File
@@ -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;
+23 -5
View File
@@ -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,
}
@@ -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);
@@ -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);
@@ -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 };
}
}
@@ -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);
}