Skip to content

Commit 2e9c093

Browse files
authored
Enhance agent feedback system with reply functionality for threaded comments (#318324)
1 parent 127d83a commit 2e9c093

12 files changed

Lines changed: 315 additions & 5 deletions

File tree

src/vs/platform/agentHost/common/agentFeedbackAttachments.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface IAgentFeedbackAttachmentItemMetadata {
1919
readonly text: string;
2020
readonly resourceUri: string;
2121
readonly range: TextRange;
22+
readonly replies?: readonly string[];
2223
}
2324

2425
export function isAgentFeedbackAttachment(attachment: MessageAttachment): attachment is SimpleMessageAttachment {
@@ -56,14 +57,24 @@ function parseAgentFeedbackAttachmentItem(item: unknown): IAgentFeedbackAttachme
5657
if (!range) {
5758
return undefined;
5859
}
60+
const replies = parseReplies(item.replies);
5961
return {
6062
id: item.id,
6163
text: item.text,
6264
resourceUri: item.resourceUri,
6365
range,
66+
replies,
6467
};
6568
}
6669

70+
function parseReplies(value: unknown): readonly string[] | undefined {
71+
if (!Array.isArray(value)) {
72+
return undefined;
73+
}
74+
const replies = value.filter(isString);
75+
return replies.length > 0 ? replies : undefined;
76+
}
77+
6778
function parseTextRange(range: unknown): TextRange | undefined {
6879
if (!isRecord(range) || !isRecord(range.start) || !isRecord(range.end)) {
6980
return undefined;

src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export class AgentFeedbackAttachmentContribution extends Disposable {
7070
codeSelection: f.codeSelection,
7171
diffHunks: f.diffHunks,
7272
sourcePRReviewCommentId: f.sourcePRReviewCommentId,
73+
replies: f.replies,
7374
})),
7475
value,
7576
};
@@ -102,6 +103,11 @@ export class AgentFeedbackAttachmentContribution extends Disposable {
102103
part += `\nDiff Hunks:\n\`\`\`diff\n${item.diffHunks}\n\`\`\``;
103104
}
104105
part += `\nComment: ${item.text}`;
106+
if (item.replies?.length) {
107+
for (const reply of item.replies) {
108+
part += `\nReply: ${reply}`;
109+
}
110+
}
105111
parts.push(part);
106112
}
107113

src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ interface ICommentItemActions {
4343
editAction: Action;
4444
convertAction: Action | undefined;
4545
removeAction: Action;
46+
addReplyAction: Action;
4647
}
4748

4849
/**
@@ -60,6 +61,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
6061
private readonly _toggleButton: HTMLElement;
6162
private readonly _bodyNode: HTMLElement;
6263
private readonly _itemElements = new Map<string, HTMLElement>();
64+
private readonly _activeReplyInputs = new Map<string, { container: HTMLElement; textarea: HTMLTextAreaElement }>();
6365

6466
private _position: IOverlayWidgetPosition | null = null;
6567
private _isExpanded: boolean = false;
@@ -181,6 +183,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
181183
private _buildFeedbackItems(): void {
182184
clearNode(this._bodyNode);
183185
this._itemElements.clear();
186+
this._activeReplyInputs.clear();
184187

185188
for (const comment of this._commentItems) {
186189
const item = $('div.agent-feedback-widget-item');
@@ -212,7 +215,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
212215
const actionBarContainer = $('div.agent-feedback-widget-item-actions');
213216
const actionBar = this._eventStore.add(new ActionBar(actionBarContainer));
214217

215-
const itemActions: ICommentItemActions = { editAction: undefined!, convertAction: undefined, removeAction: undefined! };
218+
const itemActions: ICommentItemActions = { editAction: undefined!, convertAction: undefined, removeAction: undefined!, addReplyAction: undefined! };
216219

217220
itemActions.editAction = this._eventStore.add(new Action(
218221
'agentFeedback.widget.edit',
@@ -223,6 +226,15 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
223226
));
224227
actionBar.push(itemActions.editAction, { icon: true, label: false });
225228

229+
itemActions.addReplyAction = this._eventStore.add(new Action(
230+
'agentFeedback.widget.addReply',
231+
nls.localize('addToComment', "Add to Comment"),
232+
ThemeIcon.asClassName(Codicon.commentDiscussion),
233+
true,
234+
(): void => { this._startAddingReply(comment, item, itemActions); },
235+
));
236+
actionBar.push(itemActions.addReplyAction, { icon: true, label: false });
237+
226238
if (comment.canConvertToAgentFeedback) {
227239
itemActions.convertAction = this._eventStore.add(new Action(
228240
'agentFeedback.widget.convert',
@@ -255,6 +267,10 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
255267
item.appendChild(this._renderSuggestion(comment));
256268
}
257269

270+
if (comment.replies?.length) {
271+
item.appendChild(this._renderReplies(comment.replies));
272+
}
273+
258274
this._eventStore.add(addDisposableListener(item, 'mouseenter', () => {
259275
this._highlightRange(comment);
260276
}));
@@ -268,8 +284,12 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
268284
if (target?.closest('.action-bar')) {
269285
return;
270286
}
287+
// Don't trigger navigation when interacting with the reply input.
288+
if (target?.closest('.agent-feedback-widget-add-reply')) {
289+
return;
290+
}
271291
// Don't navigate if the user just selected text inside the comment.
272-
if (target?.closest('.agent-feedback-widget-text, .agent-feedback-widget-suggestion-text')) {
292+
if (target?.closest('.agent-feedback-widget-text, .agent-feedback-widget-suggestion-text, .agent-feedback-widget-reply-text')) {
273293
const selection = this._domNode.ownerDocument.defaultView?.getSelection();
274294
if (selection && !selection.isCollapsed && this._domNode.contains(selection.anchorNode)) {
275295
return;
@@ -285,7 +305,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
285305
// triggering the editor's copy action.
286306
const onSelectableMousedown = (e: MouseEvent) => {
287307
const target = e.target as HTMLElement | null;
288-
if (target?.closest('.agent-feedback-widget-text, .agent-feedback-widget-suggestion-text')) {
308+
if (target?.closest('.agent-feedback-widget-text, .agent-feedback-widget-suggestion-text, .agent-feedback-widget-reply-text')) {
289309
this._domNode.focus({ preventScroll: true });
290310
}
291311
};
@@ -334,6 +354,22 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
334354
return suggestionNode;
335355
}
336356

357+
private _renderReplies(replies: readonly string[]): HTMLElement {
358+
const repliesNode = $('div.agent-feedback-widget-replies');
359+
360+
for (const reply of replies) {
361+
const replyNode = $('div.agent-feedback-widget-reply');
362+
const replyText = $('div.agent-feedback-widget-reply-text');
363+
const rendered = this._markdownRendererService.render(new MarkdownString(reply));
364+
this._eventStore.add(rendered);
365+
replyText.appendChild(rendered.element);
366+
replyNode.appendChild(replyText);
367+
repliesNode.appendChild(replyNode);
368+
}
369+
370+
return repliesNode;
371+
}
372+
337373
private _removeComment(comment: ISessionEditorComment): void {
338374
if (comment.source === SessionEditorCommentSource.PRReview) {
339375
this._codeReviewService.resolvePRReviewThread(this._sessionResource!, comment.sourceId);
@@ -354,6 +390,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
354390
actions.convertAction.enabled = false;
355391
}
356392
actions.removeAction.enabled = false;
393+
actions.addReplyAction.enabled = false;
357394

358395
const editStore = new DisposableStore();
359396
this._eventStore.add(editStore);
@@ -400,6 +437,118 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
400437
textarea.focus();
401438
}
402439

440+
private _startAddingReply(comment: ISessionEditorComment, itemNode: HTMLElement, actions: ICommentItemActions): void {
441+
// If a reply input is already open for this item, just focus it.
442+
const existing = this._activeReplyInputs.get(comment.id);
443+
if (existing) {
444+
existing.textarea.focus();
445+
return;
446+
}
447+
448+
// Disable item actions while replying so the action bar doesn't conflict.
449+
actions.editAction.enabled = false;
450+
if (actions.convertAction) {
451+
actions.convertAction.enabled = false;
452+
}
453+
actions.removeAction.enabled = false;
454+
actions.addReplyAction.enabled = false;
455+
456+
const replyStore = new DisposableStore();
457+
this._eventStore.add(replyStore);
458+
459+
const replyContainer = $('div.agent-feedback-widget-add-reply');
460+
const textarea = $('textarea.agent-feedback-widget-edit-textarea') as HTMLTextAreaElement;
461+
textarea.placeholder = nls.localize('addReplyPlaceholder', "Add a comment\u2026");
462+
textarea.rows = 1;
463+
replyContainer.appendChild(textarea);
464+
itemNode.appendChild(replyContainer);
465+
this._activeReplyInputs.set(comment.id, { container: replyContainer, textarea });
466+
467+
const autoSize = () => {
468+
textarea.style.height = 'auto';
469+
textarea.style.height = `${textarea.scrollHeight}px`;
470+
this._editor.layoutOverlayWidget(this);
471+
};
472+
autoSize();
473+
474+
replyStore.add(addDisposableListener(textarea, 'input', autoSize));
475+
476+
const cleanup = () => {
477+
replyStore.dispose();
478+
actions.editAction.enabled = true;
479+
if (actions.convertAction) {
480+
actions.convertAction.enabled = true;
481+
}
482+
actions.removeAction.enabled = true;
483+
actions.addReplyAction.enabled = true;
484+
this._activeReplyInputs.delete(comment.id);
485+
replyContainer.remove();
486+
this._editor.layoutOverlayWidget(this);
487+
};
488+
489+
replyStore.add(addStandardDisposableListener(textarea, 'keydown', (e) => {
490+
if (e.keyCode === KeyCode.Enter && !e.shiftKey) {
491+
e.preventDefault();
492+
e.stopPropagation();
493+
const newReply = textarea.value.trim();
494+
if (newReply) {
495+
this._saveReply(comment, newReply);
496+
// Widget will be rebuilt by the change event.
497+
} else {
498+
cleanup();
499+
}
500+
} else if (e.keyCode === KeyCode.Escape) {
501+
e.preventDefault();
502+
e.stopPropagation();
503+
cleanup();
504+
}
505+
}));
506+
507+
// Cancel the reply when focus leaves and the textarea is empty.
508+
replyStore.add(addDisposableListener(textarea, 'blur', () => {
509+
if (textarea.value.trim() === '') {
510+
cleanup();
511+
}
512+
}));
513+
514+
textarea.focus();
515+
}
516+
517+
private _saveReply(comment: ISessionEditorComment, replyText: string): void {
518+
if (comment.source === SessionEditorCommentSource.AgentFeedback) {
519+
this._agentFeedbackService.addReply(this._sessionResource, comment.sourceId, replyText);
520+
return;
521+
}
522+
523+
// For external comments (code review, PR review), convert to agent
524+
// feedback first preserving the original text, then add the reply so
525+
// that the original comment and the reply live in the same thread.
526+
if (!comment.canConvertToAgentFeedback) {
527+
return;
528+
}
529+
530+
const sourcePRReviewCommentId = comment.source === SessionEditorCommentSource.PRReview
531+
? comment.sourceId
532+
: undefined;
533+
534+
const feedback = this._agentFeedbackService.addFeedback(
535+
this._sessionResource,
536+
comment.resourceUri,
537+
comment.range,
538+
comment.text,
539+
comment.suggestion,
540+
createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range),
541+
sourcePRReviewCommentId,
542+
);
543+
this._agentFeedbackService.addReply(this._sessionResource, feedback.id, replyText);
544+
this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id));
545+
if (comment.source === SessionEditorCommentSource.CodeReview) {
546+
this._codeReviewService.removeComment(this._sessionResource, comment.sourceId);
547+
} else if (comment.source === SessionEditorCommentSource.PRReview) {
548+
this._codeReviewService.markPRReviewCommentConverted(this._sessionResource, comment.sourceId);
549+
}
550+
}
551+
403552
private _saveEdit(comment: ISessionEditorComment, newText: string): void {
404553
if (comment.source === SessionEditorCommentSource.AgentFeedback) {
405554
this._agentFeedbackService.updateFeedback(this._sessionResource, comment.sourceId, newText);
@@ -418,6 +567,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid
418567
actions.convertAction.enabled = true;
419568
}
420569
actions.removeAction.enabled = true;
570+
actions.addReplyAction.enabled = true;
421571

422572
textContainer.classList.remove('editing');
423573
clearNode(textContainer);

src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export interface IAgentFeedback {
3737
readonly diffHunks?: string;
3838
/** When this feedback was converted from a PR review comment, the original thread ID. */
3939
readonly sourcePRReviewCommentId?: string;
40+
/**
41+
* Additional comment messages that belong to the same thread as this feedback,
42+
* talking about the same code region. The first {@link text} is the initial
43+
* comment; replies are subsequent messages added to it.
44+
*/
45+
readonly replies?: readonly string[];
4046
}
4147

4248
export interface INavigableSessionComment {
@@ -78,6 +84,12 @@ export interface IAgentFeedbackService {
7884
*/
7985
updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void;
8086

87+
/**
88+
* Append a reply to an existing feedback item, making it part of the same
89+
* comment thread.
90+
*/
91+
addReply(sessionResource: URI, feedbackId: string, replyText: string): void;
92+
8193
/**
8294
* Get all feedback items for a session.
8395
*/
@@ -257,6 +269,28 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe
257269
}
258270
}
259271

272+
addReply(sessionResource: URI, feedbackId: string, replyText: string): void {
273+
const key = sessionResource.toString();
274+
const feedbackItems = this._feedbackBySession.get(key);
275+
if (!feedbackItems) {
276+
return;
277+
}
278+
279+
const idx = feedbackItems.findIndex(f => f.id === feedbackId);
280+
if (idx < 0) {
281+
return;
282+
}
283+
284+
const existing = feedbackItems[idx];
285+
const existingReplies = existing.replies ?? [];
286+
feedbackItems[idx] = {
287+
...existing,
288+
replies: [...existingReplies, replyText],
289+
};
290+
this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence);
291+
this._onDidChangeFeedback.fire({ sessionResource, feedbackItems });
292+
}
293+
260294
getFeedback(sessionResource: URI): readonly IAgentFeedback[] {
261295
return this._feedbackBySession.get(sessionResource.toString()) ?? [];
262296
}

src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,46 @@
318318
outline: none;
319319
border-color: var(--vscode-focusBorder);
320320
}
321+
322+
/* Replies — visually grouped with the parent comment but with subtle separation */
323+
.agent-feedback-widget-replies {
324+
display: flex;
325+
flex-direction: column;
326+
gap: 4px;
327+
}
328+
329+
.agent-feedback-widget-reply {
330+
padding-top: 4px;
331+
border-top: 1px dashed color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 60%, transparent);
332+
}
333+
334+
.agent-feedback-widget-reply-text {
335+
color: var(--vscode-foreground);
336+
word-wrap: break-word;
337+
user-select: text;
338+
-webkit-user-select: text;
339+
cursor: text;
340+
}
341+
342+
.agent-feedback-widget-reply-text .rendered-markdown p {
343+
margin: 0;
344+
}
345+
346+
.agent-feedback-widget-reply-text .rendered-markdown code {
347+
font-family: var(--monaco-monospace-font);
348+
font-size: 11px;
349+
padding: 1px 4px;
350+
border-radius: 3px;
351+
background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent);
352+
}
353+
354+
/* Reply composer shown when adding to a comment */
355+
.agent-feedback-widget-add-reply {
356+
padding-top: 4px;
357+
border-top: 1px dashed color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 60%, transparent);
358+
}
359+
360+
.agent-feedback-widget-add-reply .agent-feedback-widget-edit-textarea::placeholder {
361+
color: var(--vscode-input-placeholderForeground);
362+
opacity: 1;
363+
}

0 commit comments

Comments
 (0)