Skip to content

AI feedback disposable provider #4480

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: 4449-feedback-on-changelog
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions src/commands/aiFeedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ import type { AIResultContext } from '../plus/ai/aiProviderService';
import { extractAIResultContext } from '../plus/ai/utils/-webview/ai.utils';
import type { QuickPickItemOfT } from '../quickpicks/items/common';
import { command } from '../system/-webview/command';
import { setContext } from '../system/-webview/context';
import { UriMap } from '../system/-webview/uriMap';
import type { Deferrable } from '../system/function/debounce';
import { debounce } from '../system/function/debounce';
import { filterMap, map } from '../system/iterable';
import { map } from '../system/iterable';
import { Logger } from '../system/logger';
import { ActiveEditorCommand } from './commandBase';
import { getCommandUri } from './commandBase.utils';
Expand Down Expand Up @@ -45,32 +41,20 @@ export class AIFeedbackUnhelpfulCommand extends ActiveEditorCommand {

type UnhelpfulResult = { reasons?: AIFeedbackUnhelpfulReasons[]; custom?: string };

const uriResponses = new UriMap<AIFeedbackEvent['sentiment']>();
let _updateContextDebounced: Deferrable<() => void> | undefined;

async function sendFeedback(container: Container, uri: Uri, sentiment: AIFeedbackEvent['sentiment']): Promise<void> {
const context = extractAIResultContext(uri);
const context = extractAIResultContext(container, uri);
if (!context) return;

try {
const previous = uriResponses.get(uri);
const previous = container.aiFeedback.getFeedbackResponse(uri);
if (sentiment === previous) return;

let unhelpful: UnhelpfulResult | undefined;
if (sentiment === 'unhelpful') {
unhelpful = await showUnhelpfulFeedbackPicker();
}

uriResponses.set(uri, sentiment);
_updateContextDebounced ??= debounce(() => {
void setContext('gitlens:tabs:ai:helpful', [
...filterMap(uriResponses, ([uri, sentiment]) => (sentiment === 'helpful' ? uri : undefined)),
]);
void setContext('gitlens:tabs:ai:unhelpful', [
...filterMap(uriResponses, ([uri, sentiment]) => (sentiment === 'unhelpful' ? uri : undefined)),
]);
}, 100);
_updateContextDebounced();
container.aiFeedback.setFeedbackResponse(uri, sentiment);

sendFeedbackEvent(container, { source: 'ai:markdown-preview' }, context, sentiment, unhelpful);
} catch (ex) {
Expand Down
49 changes: 3 additions & 46 deletions src/commands/generateChangelog.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import type { CancellationToken, ProgressOptions, Uri } from 'vscode';
import type { CancellationToken, ProgressOptions } from 'vscode';
import { ProgressLocation, window, workspace } from 'vscode';
import type { Source } from '../constants.telemetry';
import type { Container } from '../container';
import type { GitReference } from '../git/models/reference';
import { getChangesForChangelog } from '../git/utils/-webview/log.utils';
import { createRevisionRange, shortenRevision } from '../git/utils/revision.utils';
import { showGenericErrorMessage } from '../messages';
import type { AIGenerateChangelogChanges, AIResultContext } from '../plus/ai/aiProviderService';
import type { AIGenerateChangelogChanges } from '../plus/ai/aiProviderService';
import { getAIResultContext } from '../plus/ai/utils/-webview/ai.utils';
import { showComparisonPicker } from '../quickpicks/comparisonPicker';
import { command } from '../system/-webview/command';
import { setContext } from '../system/-webview/context';
import type { Deferrable } from '../system/function/debounce';
import { debounce } from '../system/function/debounce';
import type { Lazy } from '../system/lazy';
import { lazy } from '../system/lazy';
import { Logger } from '../system/logger';
Expand All @@ -25,36 +22,6 @@ export interface GenerateChangelogCommandArgs {
source?: Source;
}

// Storage for AI feedback context associated with changelog documents
const changelogFeedbackContexts = new Map<string, AIResultContext>();
export function getChangelogFeedbackContext(documentUri: string): AIResultContext | undefined {
return changelogFeedbackContexts.get(documentUri);
}
function setChangelogFeedbackContext(documentUri: string, context: AIResultContext): void {
changelogFeedbackContexts.set(documentUri, context);
}
function clearChangelogFeedbackContext(documentUri: string): void {
changelogFeedbackContexts.delete(documentUri);
}

// Storage for changelog document URIs
const changelogUris = new Set<Uri>();
let _updateChangelogContextDebounced: Deferrable<() => void> | undefined;
function updateChangelogContext(): void {
_updateChangelogContextDebounced ??= debounce(() => {
void setContext('gitlens:tabs:ai:changelog', [...changelogUris]);
}, 100);
_updateChangelogContextDebounced();
}
function addChangelogUri(uri: Uri): void {
changelogUris.add(uri);
updateChangelogContext();
}
function removeChangelogUri(uri: Uri): void {
changelogUris.delete(uri);
updateChangelogContext();
}

@command()
export class GenerateChangelogCommand extends GlCommandBase {
constructor(private readonly container: Container) {
Expand Down Expand Up @@ -158,17 +125,7 @@ export async function generateChangelogAndOpenMarkdownDocument(
const document = await workspace.openTextDocument({ language: 'markdown', content: content });
if (feedbackContext) {
// Store feedback context for this document
setChangelogFeedbackContext(document.uri.toString(), feedbackContext);
// Add to changelog URIs context even for no-results documents
addChangelogUri(document.uri);
// Clean up context when document is closed
const disposable = workspace.onDidCloseTextDocument(closedDoc => {
if (closedDoc.uri.toString() === document.uri.toString()) {
clearChangelogFeedbackContext(document.uri.toString());
removeChangelogUri(document.uri);
disposable.dispose();
}
});
container.aiFeedback.addChangelogDocument(document.uri, feedbackContext);
}
await window.showTextDocument(document);
}
9 changes: 9 additions & 0 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type { Storage } from './system/-webview/storage';
import { memoize } from './system/decorators/-webview/memoize';
import { log } from './system/decorators/log';
import { Logger } from './system/logger';
import { AIFeedbackProvider } from './telemetry/aiFeedbackProvider';
import { TelemetryService } from './telemetry/telemetry';
import { UsageTracker } from './telemetry/usageTracker';
import { isWalkthroughSupported, WalkthroughStateProvider } from './telemetry/walkthroughStateProvider';
Expand Down Expand Up @@ -365,6 +366,14 @@ export class Container {
return this._ai;
}

private _aiFeedback: AIFeedbackProvider | undefined;
get aiFeedback(): AIFeedbackProvider {
if (this._aiFeedback == null) {
this._disposables.push((this._aiFeedback = new AIFeedbackProvider()));
}
return this._aiFeedback;
}

private _autolinks: AutolinksProvider | undefined;
get autolinks(): AutolinksProvider {
if (this._autolinks == null) {
Expand Down
5 changes: 2 additions & 3 deletions src/plus/ai/utils/-webview/ai.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Disposable, QuickInputButton } from 'vscode';
import { env, ThemeIcon, Uri, window } from 'vscode';
import { getChangelogFeedbackContext } from '../../../../commands/generateChangelog';
import { Schemes } from '../../../../constants';
import type { AIProviders } from '../../../../constants.ai';
import type { Container } from '../../../../container';
Expand Down Expand Up @@ -282,7 +281,7 @@ export function getAIResultContext(result: AIResult): AIResultContext {
};
}

export function extractAIResultContext(uri: Uri | undefined): AIResultContext | undefined {
export function extractAIResultContext(container: Container, uri: Uri | undefined): AIResultContext | undefined {
if (uri?.scheme === Schemes.GitLensAIMarkdown) {
const { authority } = uri;
if (!authority) return undefined;
Expand All @@ -299,7 +298,7 @@ export function extractAIResultContext(uri: Uri | undefined): AIResultContext |
// Check for untitled documents with stored changelog feedback context
if (uri?.scheme === 'untitled') {
try {
return getChangelogFeedbackContext(uri.toString());
return container.aiFeedback.getChangelogDocument(uri.toString());
} catch {
return undefined;
}
Expand Down
97 changes: 97 additions & 0 deletions src/telemetry/aiFeedbackProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Disposable, Uri } from 'vscode';
import { workspace } from 'vscode';
import type { AIFeedbackEvent } from '../constants.telemetry';
import type { AIResultContext } from '../plus/ai/aiProviderService';
import { setContext } from '../system/-webview/context';
import { UriMap } from '../system/-webview/uriMap';
import type { Deferrable } from '../system/function/debounce';
import { debounce } from '../system/function/debounce';
import { filterMap } from '../system/iterable';

export class AIFeedbackProvider implements Disposable {
constructor() {
// Listen for document close events to clean up contexts
this._disposables.push(
workspace.onDidCloseTextDocument(document => this.removeChangelogDocument(document.uri)),
);
}

public addChangelogDocument(uri: Uri, context: AIResultContext): void {
this.setChangelogDocument(uri.toString(), context);
this.addChangelogUri(uri);
}

private removeChangelogDocument(uri: Uri): void {
this.deleteChangelogDocument(uri.toString());
this.removeChangelogUri(uri);
}

private readonly _disposables: Disposable[] = [];
dispose(): void {
this._disposables.forEach(d => void d.dispose());
this._uriResponses.clear();
this._changelogDocuments.clear();
this._changelogUris.clear();
this._updateFeedbackContextDebounced = undefined;
this._updateChangelogContextDebounced = undefined;
}

// Storage for changelog document URIs
private readonly _changelogUris = new Set<Uri>();
private _updateChangelogContextDebounced: Deferrable<() => void> | undefined;
private updateChangelogContext(): void {
this._updateChangelogContextDebounced ??= debounce(() => {
void setContext('gitlens:tabs:ai:changelog', [...this._changelogUris]);
}, 100);
this._updateChangelogContextDebounced();
}
private addChangelogUri(uri: Uri): void {
if (!this._changelogUris.has(uri)) {
this._changelogUris.add(uri);
this.updateChangelogContext();
}
}
private removeChangelogUri(uri: Uri): void {
if (this._changelogUris.has(uri)) {
this._changelogUris.delete(uri);
this.updateChangelogContext();
}
}

// Storage for AI feedback context associated with changelog documents
private readonly _changelogDocuments = new Map<string, AIResultContext>();
getChangelogDocument(documentUri: string): AIResultContext | undefined {
return this._changelogDocuments.get(documentUri);
}
private setChangelogDocument(documentUri: string, context: AIResultContext): void {
this._changelogDocuments.set(documentUri, context);
}
private deleteChangelogDocument(documentUri: string): void {
this._changelogDocuments.delete(documentUri);
}

// Storage for AI feedback responses by URI
private readonly _uriResponses = new UriMap<AIFeedbackEvent['sentiment']>();
private _updateFeedbackContextDebounced: Deferrable<() => void> | undefined;
private updateFeedbackContext(): void {
this._updateFeedbackContextDebounced ??= debounce(() => {
void setContext('gitlens:tabs:ai:helpful', [
...filterMap(this._uriResponses, ([uri, sentiment]) => (sentiment === 'helpful' ? uri : undefined)),
]);
void setContext('gitlens:tabs:ai:unhelpful', [
...filterMap(this._uriResponses, ([uri, sentiment]) => (sentiment === 'unhelpful' ? uri : undefined)),
]);
}, 100);
this._updateFeedbackContextDebounced();
}
setFeedbackResponse(uri: Uri, sentiment: AIFeedbackEvent['sentiment']): void {
const previous = this._uriResponses.get(uri);
if (sentiment === previous) return;

this._uriResponses.set(uri, sentiment);
this.updateFeedbackContext();
}
getFeedbackResponse(uri: Uri): AIFeedbackEvent['sentiment'] | undefined {
return this._uriResponses.get(uri);
}
}