Path: blob/main/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from '../../../../base/common/event.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { URI } from '../../../../base/common/uri.js';8import { IRange } from '../../../../editor/common/core/range.js';9import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';10import { generateUuid } from '../../../../base/common/uuid.js';11import { isEqual } from '../../../../base/common/resources.js';12import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';13import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';14import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';15import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js';16import { changeMatchesResource, IAgentFeedbackContext } from './agentFeedbackEditorUtils.js';17import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';18import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';19import { ICommandService } from '../../../../platform/commands/common/commands.js';20import { ILogService } from '../../../../platform/log/common/log.js';21import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js';22import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';23import { logChangesViewReviewCommentAdded } from '../../../common/sessionsTelemetry.js';24import { ISessionFileChange } from '../../../services/sessions/common/session.js';2526// --- Types --------------------------------------------------------------------2728export interface IAgentFeedback {29readonly id: string;30readonly text: string;31readonly resourceUri: URI;32readonly range: IRange;33readonly sessionResource: URI;34readonly suggestion?: ICodeReviewSuggestion;35readonly codeSelection?: string;36readonly diffHunks?: string;37/** When this feedback was converted from a PR review comment, the original thread ID. */38readonly sourcePRReviewCommentId?: string;39}4041export interface INavigableSessionComment {42readonly id: string;43}4445export interface IAgentFeedbackChangeEvent {46readonly sessionResource: URI;47readonly feedbackItems: readonly IAgentFeedback[];48}4950export interface IAgentFeedbackNavigationBearing {51readonly activeIdx: number;52readonly totalCount: number;53}5455// --- Service Interface --------------------------------------------------------5657export const IAgentFeedbackService = createDecorator<IAgentFeedbackService>('agentFeedbackService');5859export interface IAgentFeedbackService {60readonly _serviceBrand: undefined;6162readonly onDidChangeFeedback: Event<IAgentFeedbackChangeEvent>;63readonly onDidChangeNavigation: Event<URI>;6465/**66* Add a feedback item for the given session.67*/68addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback;6970/**71* Remove a single feedback item.72*/73removeFeedback(sessionResource: URI, feedbackId: string): void;7475/**76* Update the text of an existing feedback item.77*/78updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void;7980/**81* Get all feedback items for a session.82*/83getFeedback(sessionResource: URI): readonly IAgentFeedback[];8485/**86* Resolve the most recently updated session that has feedback for a given resource.87*/88getMostRecentSessionForResource(resourceUri: URI): URI | undefined;8990/**91* Set the navigation anchor to a specific feedback item, open its editor, and fire a navigation event.92*/93revealFeedback(sessionResource: URI, feedbackId: string): Promise<void>;9495/**96* Open an editor for the given session comment (feedback or code-review) at its range97* and set it as the navigation anchor.98*/99revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise<void>;100101/**102* Navigate to next/previous feedback item in a session.103*/104getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined;105getNextNavigableItem<T extends INavigableSessionComment>(sessionResource: URI, items: readonly T[], next: boolean): T | undefined;106setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void;107108/**109* Get the current navigation bearings for a session.110*/111getNavigationBearing(sessionResource: URI, items?: readonly INavigableSessionComment[]): IAgentFeedbackNavigationBearing;112113/**114* Clear all feedback items for a session (e.g., after sending).115*/116clearFeedback(sessionResource: URI): void;117118/**119* Add a feedback item and then submit the feedback. Waits for the120* attachment to be updated in the chat widget before submitting.121*/122addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise<void>;123}124125// --- Implementation -----------------------------------------------------------126127export class AgentFeedbackService extends Disposable implements IAgentFeedbackService {128129declare readonly _serviceBrand: undefined;130131private readonly _onDidChangeFeedback = this._store.add(new Emitter<IAgentFeedbackChangeEvent>());132readonly onDidChangeFeedback = this._onDidChangeFeedback.event;133private readonly _onDidChangeNavigation = this._store.add(new Emitter<URI>());134readonly onDidChangeNavigation = this._onDidChangeNavigation.event;135136/** sessionResource → feedback items */137private readonly _feedbackBySession = new Map<string, IAgentFeedback[]>();138private readonly _sessionUpdatedOrder = new Map<string, number>();139private _sessionUpdatedSequence = 0;140private readonly _navigationAnchorBySession = new Map<string, string>();141142constructor(143@IChatEditingService private readonly _chatEditingService: IChatEditingService,144@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,145@IEditorService private readonly _editorService: IEditorService,146@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,147@ICommandService private readonly _commandService: ICommandService,148@ILogService private readonly _logService: ILogService,149@ITelemetryService private readonly _telemetryService: ITelemetryService,150) {151super();152}153154addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback {155const key = sessionResource.toString();156let feedbackItems = this._feedbackBySession.get(key);157if (!feedbackItems) {158feedbackItems = [];159this._feedbackBySession.set(key, feedbackItems);160}161162const feedback: IAgentFeedback = {163id: generateUuid(),164text,165resourceUri,166range,167sessionResource,168suggestion,169codeSelection: context?.codeSelection,170diffHunks: context?.diffHunks,171sourcePRReviewCommentId,172};173174// Insert at the correct sorted position.175// Files are grouped by recency: first feedback for a new file appears after176// all existing files. Within a file, items are sorted by startLineNumber.177const resourceStr = resourceUri.toString();178const hasExistingForFile = feedbackItems.some(f => f.resourceUri.toString() === resourceStr);179180if (!hasExistingForFile) {181// New file — append at the end182feedbackItems.push(feedback);183} else {184// Find insertion point: after the last item for a different file that185// precedes this file's block, then within this file's block by line number.186let insertIdx = feedbackItems.length;187for (let i = 0; i < feedbackItems.length; i++) {188if (feedbackItems[i].resourceUri.toString() === resourceStr189&& feedbackItems[i].range.startLineNumber > range.startLineNumber) {190insertIdx = i;191break;192}193// If we passed the last item for this file without finding a larger194// line number, insert right after the file's block.195if (feedbackItems[i].resourceUri.toString() === resourceStr) {196insertIdx = i + 1;197}198}199feedbackItems.splice(insertIdx, 0, feedback);200}201202this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);203this._onDidChangeNavigation.fire(sessionResource);204205this._onDidChangeFeedback.fire({ sessionResource, feedbackItems });206207logChangesViewReviewCommentAdded(this._telemetryService, {208hasExistingFeedback: hasExistingForFile,209hasSuggestion: !!suggestion,210isFromPRReview: !!sourcePRReviewCommentId,211});212213return feedback;214}215216removeFeedback(sessionResource: URI, feedbackId: string): void {217const key = sessionResource.toString();218const feedbackItems = this._feedbackBySession.get(key);219if (!feedbackItems) {220return;221}222223const idx = feedbackItems.findIndex(f => f.id === feedbackId);224if (idx >= 0) {225feedbackItems.splice(idx, 1);226if (this._navigationAnchorBySession.get(key) === feedbackId) {227this._navigationAnchorBySession.delete(key);228this._onDidChangeNavigation.fire(sessionResource);229}230if (feedbackItems.length > 0) {231this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);232} else {233this._sessionUpdatedOrder.delete(key);234}235236this._onDidChangeFeedback.fire({ sessionResource, feedbackItems });237}238}239240updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void {241const key = sessionResource.toString();242const feedbackItems = this._feedbackBySession.get(key);243if (!feedbackItems) {244return;245}246247const idx = feedbackItems.findIndex(f => f.id === feedbackId);248if (idx >= 0) {249const existing = feedbackItems[idx];250feedbackItems[idx] = {251...existing,252text: newText,253};254this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);255this._onDidChangeFeedback.fire({ sessionResource, feedbackItems });256}257}258259getFeedback(sessionResource: URI): readonly IAgentFeedback[] {260return this._feedbackBySession.get(sessionResource.toString()) ?? [];261}262263getMostRecentSessionForResource(resourceUri: URI): URI | undefined {264let bestSession: URI | undefined;265let bestSequence = -1;266267for (const [, feedbackItems] of this._feedbackBySession) {268if (!feedbackItems.length) {269continue;270}271272const candidate = feedbackItems[0].sessionResource;273if (!this._sessionContainsResource(candidate, resourceUri, feedbackItems)) {274continue;275}276277const sequence = this._sessionUpdatedOrder.get(candidate.toString()) ?? 0;278if (sequence > bestSequence) {279bestSession = candidate;280bestSequence = sequence;281}282}283284return bestSession;285}286287private _sessionContainsResource(sessionResource: URI, resourceUri: URI, feedbackItems: readonly IAgentFeedback[]): boolean {288if (feedbackItems.some(item => isEqual(item.resourceUri, resourceUri))) {289return true;290}291292for (const editingSession of this._chatEditingService.editingSessionsObs.get()) {293if (!isEqual(editingSession.chatSessionResource, sessionResource)) {294continue;295}296297if (editingEntriesContainResource(editingSession.entries.get(), resourceUri)) {298return true;299}300}301302const session = this._sessionsManagementService.getSession(sessionResource);303if (!session) {304return false;305}306307const changes = session.changes.get();308if (changes.some(change => changeMatchesResource(change, resourceUri))) {309return true;310}311312return false;313}314315async revealFeedback(sessionResource: URI, feedbackId: string): Promise<void> {316const key = sessionResource.toString();317const feedbackItems = this._feedbackBySession.get(key);318const feedback = feedbackItems?.find(f => f.id === feedbackId);319if (!feedback) {320return;321}322await this.revealSessionComment(sessionResource, feedbackId, feedback.resourceUri, feedback.range);323}324325async revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise<void> {326const selection = { startLineNumber: range.startLineNumber, startColumn: range.startColumn };327const sessionData = this._sessionsManagementService.getSession(sessionResource);328const sessionChange = this._getSessionChange(resourceUri, sessionData?.changes.get());329330if (sessionChange?.isDeletion && sessionChange.originalUri) {331await this._editorService.openEditor({332resource: sessionChange.originalUri,333options: {334modal: {},335preserveFocus: false,336revealIfVisible: true,337selection,338}339});340} else if (sessionChange?.originalUri) {341await this._editorService.openEditor({342original: { resource: sessionChange.originalUri },343modified: { resource: sessionChange.modifiedUri },344options: {345modal: {},346preserveFocus: false,347revealIfVisible: true,348selection,349}350});351} else {352await this._editorService.openEditor({353resource: sessionChange?.modifiedUri ?? resourceUri,354options: {355modal: {},356preserveFocus: false,357revealIfVisible: true,358selection,359}360});361}362363this.setNavigationAnchor(sessionResource, commentId);364}365366private _getSessionChange(resourceUri: URI, changes: readonly ISessionFileChange[] | undefined): { originalUri?: URI; modifiedUri: URI; isDeletion: boolean } | undefined {367if (!(changes instanceof Array)) {368return undefined;369}370371const matchingChange = changes.find(change => changeMatchesResource(change, resourceUri));372if (!matchingChange) {373return undefined;374}375376if (isIChatSessionFileChange2(matchingChange)) {377return {378originalUri: matchingChange.originalUri,379modifiedUri: matchingChange.modifiedUri ?? matchingChange.uri,380isDeletion: matchingChange.modifiedUri === undefined,381};382}383384return {385originalUri: matchingChange.originalUri,386modifiedUri: matchingChange.modifiedUri,387isDeletion: false,388};389}390391getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined {392return this.getNextNavigableItem(sessionResource, this.getFeedback(sessionResource), next);393}394395getNextNavigableItem<T extends INavigableSessionComment>(sessionResource: URI, items: readonly T[], next: boolean): T | undefined {396const key = sessionResource.toString();397if (!items.length) {398this._navigationAnchorBySession.delete(key);399return undefined;400}401402const anchorId = this._navigationAnchorBySession.get(key);403let anchorIndex = anchorId ? items.findIndex(item => item.id === anchorId) : -1;404405if (anchorIndex < 0 && !next) {406anchorIndex = 0;407}408409const nextIndex = next410? (anchorIndex + 1) % items.length411: (anchorIndex - 1 + items.length) % items.length;412413const item = items[nextIndex];414this.setNavigationAnchor(sessionResource, item.id);415return item;416}417418setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void {419const key = sessionResource.toString();420if (itemId) {421this._navigationAnchorBySession.set(key, itemId);422} else {423this._navigationAnchorBySession.delete(key);424}425this._onDidChangeNavigation.fire(sessionResource);426}427428getNavigationBearing(sessionResource: URI, items: readonly INavigableSessionComment[] = this._feedbackBySession.get(sessionResource.toString()) ?? []): IAgentFeedbackNavigationBearing {429const key = sessionResource.toString();430const anchorId = this._navigationAnchorBySession.get(key);431const activeIdx = anchorId ? items.findIndex(item => item.id === anchorId) : -1;432return { activeIdx, totalCount: items.length };433}434435clearFeedback(sessionResource: URI): void {436const key = sessionResource.toString();437this._feedbackBySession.delete(key);438this._sessionUpdatedOrder.delete(key);439this._navigationAnchorBySession.delete(key);440this._onDidChangeNavigation.fire(sessionResource);441this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] });442}443444async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise<void> {445this.addFeedback(sessionResource, resourceUri, range, text, suggestion, context, sourcePRReviewCommentId);446447// Wait for the attachment contribution to update the chat widget's attachment model448const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource);449if (widget) {450const attachmentId = 'agentFeedback:' + sessionResource.toString();451const hasAttachment = () => widget.attachmentModel.attachments.some(a => a.id === attachmentId);452453if (!hasAttachment()) {454await Event.toPromise(455Event.filter(widget.attachmentModel.onDidChange, () => hasAttachment())456);457}458} else {459this._logService.error('[AgentFeedback] addFeedbackAndSubmit: no chat widget found for session, feedback may not be submitted correctly', sessionResource.toString());460await new Promise(resolve => setTimeout(resolve, 100));461}462463try {464await this._commandService.executeCommand('agentFeedbackEditor.action.submit');465} catch (err) {466this._logService.error('[AgentFeedback] Failed to execute submit feedback command', err);467}468}469}470471472