Skip to content

Commit b78e0d0

Browse files
sergeibbbeamodio
authored andcommitted
Adds AI feedback toolbar actions to markdown previews
Introduces dedicated "Helpful" and "Not helpful" toolbar commands for AI-generated markdown previews, allowing users to quickly provide positive or negative feedback directly from the editor UI. Feedback context is extracted from document metadata when telemetry is enabled, improving the feedback workflow and user experience for AI-assisted features. (#4449, #4475)
1 parent f7facea commit b78e0d0

12 files changed

+382
-13
lines changed

contributions.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,32 @@
143143
]
144144
}
145145
},
146+
"gitlens.ai.feedback.negative": {
147+
"label": "AI Feedback: Negative",
148+
"icon": "$(thumbsdown)",
149+
"menus": {
150+
"editor/title": [
151+
{
152+
"when": "resourceScheme == gitlens-markdown && config.gitlens.telemetry.enabled && activeCustomEditorId == vscode.markdown.preview.editor",
153+
"group": "navigation",
154+
"order": 2
155+
}
156+
]
157+
}
158+
},
159+
"gitlens.ai.feedback.positive": {
160+
"label": "AI Feedback: Positive",
161+
"icon": "$(thumbsup)",
162+
"menus": {
163+
"editor/title": [
164+
{
165+
"when": "resourceScheme == gitlens-markdown && config.gitlens.telemetry.enabled && activeCustomEditorId == vscode.markdown.preview.editor",
166+
"group": "navigation",
167+
"order": 1
168+
}
169+
]
170+
}
171+
},
146172
"gitlens.ai.generateChangelog": {
147173
"label": "Generate Changelog (Preview)...",
148174
"commandPalette": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"

docs/telemetry-events.md

Lines changed: 34 additions & 2 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6192,6 +6192,16 @@
61926192
"title": "Explain Working Changes (Preview)",
61936193
"icon": "$(sparkle)"
61946194
},
6195+
{
6196+
"command": "gitlens.ai.feedback.negative",
6197+
"title": "AI Feedback: Negative",
6198+
"icon": "$(thumbsdown)"
6199+
},
6200+
{
6201+
"command": "gitlens.ai.feedback.positive",
6202+
"title": "AI Feedback: Positive",
6203+
"icon": "$(thumbsup)"
6204+
},
61956205
{
61966206
"command": "gitlens.ai.generateChangelog",
61976207
"title": "Generate Changelog (Preview)...",
@@ -10722,6 +10732,14 @@
1072210732
"command": "gitlens.ai.explainWip:views",
1072310733
"when": "false"
1072410734
},
10735+
{
10736+
"command": "gitlens.ai.feedback.negative",
10737+
"when": "false"
10738+
},
10739+
{
10740+
"command": "gitlens.ai.feedback.positive",
10741+
"when": "false"
10742+
},
1072510743
{
1072610744
"command": "gitlens.ai.generateChangelog",
1072710745
"when": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
@@ -14118,6 +14136,16 @@
1411814136
"when": "resourceScheme == gitlens-markdown && activeCustomEditorId == vscode.markdown.preview.editor",
1411914137
"group": "navigation@0"
1412014138
},
14139+
{
14140+
"command": "gitlens.ai.feedback.positive",
14141+
"when": "resourceScheme == gitlens-markdown && config.gitlens.telemetry.enabled && activeCustomEditorId == vscode.markdown.preview.editor",
14142+
"group": "navigation@1"
14143+
},
14144+
{
14145+
"command": "gitlens.ai.feedback.negative",
14146+
"when": "resourceScheme == gitlens-markdown && config.gitlens.telemetry.enabled && activeCustomEditorId == vscode.markdown.preview.editor",
14147+
"group": "navigation@2"
14148+
},
1412114149
{
1412214150
"command": "gitlens.openPatch",
1412314151
"when": "false && editorLangId == diff"

src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import './commands/addAuthors';
2+
import './commands/aiFeedback';
23
import './commands/browseRepoAtRevision';
34
import './commands/closeUnchangedFiles';
45
import './commands/cloudIntegrations';

src/commands/aiFeedback.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import type { TextEditor, Uri } from 'vscode';
2+
import { window } from 'vscode';
3+
import { Schemes } from '../constants';
4+
import type { AIFeedbackEvent, Source } from '../constants.telemetry';
5+
import type { Container } from '../container';
6+
import type { MarkdownContentMetadata } from '../documents/markdown';
7+
import { decodeGitLensRevisionUriAuthority } from '../git/gitUri.authority';
8+
import { command } from '../system/-webview/command';
9+
import { Logger } from '../system/logger';
10+
import { ActiveEditorCommand } from './commandBase';
11+
import { getCommandUri } from './commandBase.utils';
12+
13+
export interface AIFeedbackContext {
14+
feature: AIFeedbackEvent['feature'];
15+
model: {
16+
id: string;
17+
providerId: string;
18+
providerName: string;
19+
};
20+
usage?: {
21+
promptTokens?: number;
22+
completionTokens?: number;
23+
totalTokens?: number;
24+
limits?: {
25+
used: number;
26+
limit: number;
27+
resetsOn: Date;
28+
};
29+
};
30+
outputLength: number;
31+
}
32+
33+
@command()
34+
export class AIFeedbackPositiveCommand extends ActiveEditorCommand {
35+
constructor(private readonly container: Container) {
36+
super('gitlens.ai.feedback.positive');
37+
}
38+
39+
execute(editor?: TextEditor, uri?: Uri): void {
40+
const context = this.extractFeedbackContext(editor, uri);
41+
if (!context) return;
42+
43+
try {
44+
// For positive feedback, just send the event immediately without showing any form
45+
sendFeedbackEvent(
46+
this.container,
47+
context,
48+
'positive',
49+
{
50+
presetReasons: [],
51+
writeInFeedback: '',
52+
},
53+
{ source: 'markdown-preview' },
54+
);
55+
56+
void window.showInformationMessage('Thank you for your feedback!');
57+
} catch (ex) {
58+
Logger.error(ex, 'AIFeedbackPositiveCommand', 'execute');
59+
}
60+
}
61+
62+
private extractFeedbackContext(editor?: TextEditor, uri?: Uri): AIFeedbackContext | undefined {
63+
uri = getCommandUri(uri, editor);
64+
if (uri?.scheme !== Schemes.GitLensMarkdown) return undefined;
65+
66+
const authority = uri.authority;
67+
if (!authority) return undefined;
68+
69+
try {
70+
const metadata = decodeGitLensRevisionUriAuthority<MarkdownContentMetadata>(authority);
71+
72+
// Extract feedback context from metadata
73+
if (metadata.feedbackContext) {
74+
return metadata.feedbackContext as unknown as AIFeedbackContext;
75+
}
76+
77+
return undefined;
78+
} catch (ex) {
79+
Logger.error(ex, 'AIFeedbackPositiveCommand', 'extractFeedbackContext');
80+
return undefined;
81+
}
82+
}
83+
}
84+
85+
@command()
86+
export class AIFeedbackNegativeCommand extends ActiveEditorCommand {
87+
constructor(private readonly container: Container) {
88+
super('gitlens.ai.feedback.negative');
89+
}
90+
91+
async execute(editor?: TextEditor, uri?: Uri): Promise<void> {
92+
const context = this.extractFeedbackContext(editor, uri);
93+
if (!context) return;
94+
95+
try {
96+
// For negative feedback, always show the detailed form directly
97+
await showDetailedFeedbackForm(this.container, context);
98+
} catch (ex) {
99+
Logger.error(ex, 'AIFeedbackNegativeCommand', 'execute');
100+
}
101+
}
102+
103+
private extractFeedbackContext(editor?: TextEditor, uri?: Uri): AIFeedbackContext | undefined {
104+
uri = getCommandUri(uri, editor);
105+
if (uri?.scheme !== Schemes.GitLensMarkdown) return undefined;
106+
107+
const authority = uri.authority;
108+
if (!authority) return undefined;
109+
110+
try {
111+
const metadata = decodeGitLensRevisionUriAuthority<MarkdownContentMetadata>(authority);
112+
113+
// Extract feedback context from metadata
114+
if (metadata.feedbackContext) {
115+
return metadata.feedbackContext as unknown as AIFeedbackContext;
116+
}
117+
118+
return undefined;
119+
} catch (ex) {
120+
Logger.error(ex, 'AIFeedbackNegativeCommand', 'extractFeedbackContext');
121+
return undefined;
122+
}
123+
}
124+
}
125+
126+
async function showDetailedFeedbackForm(container: Container, context: AIFeedbackContext): Promise<void> {
127+
const negativeReasons = [
128+
'Inaccurate or incorrect response',
129+
'Too generic or not specific enough',
130+
'Poor code quality',
131+
'Missing important details',
132+
'Difficult to understand',
133+
'Not relevant to my needs',
134+
];
135+
136+
// Show quick pick for preset reasons
137+
const selectedReasons = await window.showQuickPick(
138+
negativeReasons.map(reason => ({ label: reason, picked: false })),
139+
{
140+
title: 'What specifically could be improved?',
141+
canPickMany: true,
142+
placeHolder: 'Select all that apply (optional)',
143+
},
144+
);
145+
146+
// Show input box for additional feedback
147+
const writeInFeedback = await window.showInputBox({
148+
title: 'Additional feedback (optional)',
149+
placeHolder: 'Tell us more about your experience...',
150+
prompt: 'Your feedback helps us improve our AI features',
151+
});
152+
153+
// Always send feedback submission telemetry for negative feedback
154+
sendFeedbackEvent(
155+
container,
156+
context,
157+
'negative',
158+
{
159+
presetReasons: selectedReasons?.map(r => r.label),
160+
writeInFeedback: writeInFeedback,
161+
},
162+
{ source: 'markdown-preview' },
163+
);
164+
165+
void window.showInformationMessage('Thank you for your feedback!');
166+
}
167+
168+
function sendFeedbackEvent(
169+
container: Container,
170+
context: AIFeedbackContext,
171+
rating: 'positive' | 'negative',
172+
feedback: {
173+
presetReasons?: string[];
174+
writeInFeedback?: string;
175+
},
176+
source: Source,
177+
): void {
178+
const hasPresetReasons = feedback.presetReasons && feedback.presetReasons.length > 0;
179+
const writeInFeedback = feedback.writeInFeedback?.trim() ?? undefined;
180+
181+
let feedbackType: 'preset' | 'writeIn' | 'both';
182+
if (hasPresetReasons && writeInFeedback?.length) {
183+
feedbackType = 'both';
184+
} else if (hasPresetReasons) {
185+
feedbackType = 'preset';
186+
} else {
187+
feedbackType = 'writeIn';
188+
}
189+
190+
const eventData: AIFeedbackEvent = {
191+
feature: context.feature,
192+
rating: rating,
193+
feedbackType: feedbackType,
194+
presetReason: hasPresetReasons ? feedback.presetReasons!.join(', ') : undefined,
195+
'writeInFeedback.length': writeInFeedback?.length ?? undefined,
196+
'writeInFeedback.text': writeInFeedback?.length ? writeInFeedback : undefined,
197+
'model.id': context.model.id,
198+
'model.provider.id': context.model.providerId as any,
199+
'model.provider.name': context.model.providerName,
200+
'usage.promptTokens': context.usage?.promptTokens,
201+
'usage.completionTokens': context.usage?.completionTokens,
202+
'usage.totalTokens': context.usage?.totalTokens,
203+
'usage.limits.used': context.usage?.limits?.used,
204+
'usage.limits.limit': context.usage?.limits?.limit,
205+
'usage.limits.resetsOn': context.usage?.limits?.resetsOn?.toISOString(),
206+
'output.length': context.outputLength,
207+
};
208+
209+
container.telemetry.sendEvent('ai/feedback', eventData, source);
210+
}

src/commands/explainBase.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { GitUri } from '../git/gitUri';
88
import type { AIExplainSource, AISummarizeResult } from '../plus/ai/aiProviderService';
99
import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker';
1010
import { showMarkdownPreview } from '../system/-webview/markdown';
11+
import type { AIFeedbackContext } from './aiFeedback';
1112
import { GlCommandBase } from './commandBase';
1213
import { getCommandUri } from './commandBase.utils';
1314

@@ -55,7 +56,8 @@ export abstract class ExplainCommandBase extends GlCommandBase {
5556
}
5657

5758
protected openDocument(result: AISummarizeResult, path: string, metadata: MarkdownContentMetadata): void {
58-
const content = `${getMarkdownHeaderContent(metadata)}\n\n${result.parsed.summary}\n\n${result.parsed.body}`;
59+
const headerContent = getMarkdownHeaderContent(metadata, this.container.telemetry.enabled);
60+
const content = `${headerContent}\n\n${result.parsed.summary}\n\n${result.parsed.body}`;
5961

6062
const documentUri = this.container.markdown.openDocument(content, path, metadata.header.title, metadata);
6163

src/commands/generateRebase.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { command, executeCommand } from '../system/-webview/command';
1717
import { showMarkdownPreview } from '../system/-webview/markdown';
1818
import { Logger } from '../system/logger';
1919
import { escapeMarkdownCodeBlocks } from '../system/markdown';
20+
import type { AIFeedbackContext } from './aiFeedback';
2021
import { GlCommandBase } from './commandBase';
2122
import type { CommandContext } from './commandContext';
2223
import {
@@ -349,8 +350,21 @@ export async function generateRebase(
349350
// Extract the diff information from the reorganized commits
350351
const diffInfo = extractRebaseDiffInfo(result.commits, result.diff, result.hunkMap);
351352

353+
// Create feedback context for telemetry
354+
const feedbackContext: AIFeedbackContext = {
355+
feature: 'generateRebase',
356+
model: {
357+
id: result.model.id,
358+
providerId: result.model.provider.id,
359+
providerName: result.model.provider.name,
360+
},
361+
usage: result.usage,
362+
outputLength: result.content?.length,
363+
};
364+
const telemetryEnabled = container.telemetry.enabled;
365+
352366
// Generate the markdown content that shows each commit and its diffs
353-
const { content, metadata } = generateRebaseMarkdown(result, title);
367+
const { content, metadata } = generateRebaseMarkdown(result, title, telemetryEnabled, feedbackContext);
354368

355369
let generateType: 'commits' | 'rebase' = 'rebase';
356370
let headRefSlug = head.ref;
@@ -522,20 +536,30 @@ export function extractRebaseDiffInfo(
522536
function generateRebaseMarkdown(
523537
result: AIRebaseResult,
524538
title = 'Rebase Commits',
539+
telemetryEnabled: boolean,
540+
feedbackContext?: AIFeedbackContext,
525541
): { content: string; metadata: MarkdownContentMetadata } {
526-
const metadata = {
542+
const metadata: MarkdownContentMetadata = {
527543
header: {
528544
title: title,
529545
aiModel: result.model.name,
530546
subtitle: 'Explanation',
531547
},
532548
};
533549

550+
// Always store feedback context if available, but only show UI when telemetry is enabled
551+
if (feedbackContext) {
552+
metadata.feedbackContext = feedbackContext as unknown as Record<string, unknown>;
553+
}
554+
534555
let markdown = '';
535556
if (result.commits.length === 0) {
536557
markdown = 'No Commits Generated';
537558

538-
return { content: `${getMarkdownHeaderContent(metadata)}\n\n${markdown}`, metadata: metadata };
559+
return {
560+
content: `${getMarkdownHeaderContent(metadata, telemetryEnabled)}\n\n${markdown}`,
561+
metadata: metadata,
562+
};
539563
}
540564
const { commits, diff: originalDiff, hunkMap } = result;
541565

@@ -602,7 +626,7 @@ function generateRebaseMarkdown(
602626
// markdown += `\n\n----\n\n## Raw commits\n\n\`\`\`${escapeMarkdownCodeBlocks(JSON.stringify(commits))}\`\`\``;
603627
// markdown += `\n\n----\n\n## Original Diff\n\n\`\`\`${escapeMarkdownCodeBlocks(originalDiff)}\`\`\`\n`;
604628

605-
return { content: `${getMarkdownHeaderContent(metadata)}\n\n${markdown}`, metadata: metadata };
629+
return { content: `${getMarkdownHeaderContent(metadata, telemetryEnabled)}\n\n${markdown}`, metadata: metadata };
606630
}
607631

608632
/**

src/constants.commands.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type ContributedCommands =
1212
| 'gitlens.ai.explainStash:views'
1313
| 'gitlens.ai.explainWip:graph'
1414
| 'gitlens.ai.explainWip:views'
15+
| 'gitlens.ai.feedback.negative'
16+
| 'gitlens.ai.feedback.positive'
1517
| 'gitlens.ai.generateChangelog:views'
1618
| 'gitlens.ai.generateChangelogFrom:graph'
1719
| 'gitlens.ai.generateChangelogFrom:views'

0 commit comments

Comments
 (0)