Skip to content

Opens summary before AI result #4489

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 5 commits into
base: 4449-feedback-provider
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
### Changed

- Changes branch creation to avoid setting an upstream branch if the new branch name and remote branch name don't match ([#4477](https://github.com/gitkraken/vscode-gitlens/issues/4477))
- Supports opening an explain summary document before summary content is generated ([#4328](https://github.com/gitkraken/vscode-gitlens/issues/4328))

### Fixed

Expand Down
95 changes: 90 additions & 5 deletions src/commands/explainBase.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { TextEditor, Uri } from 'vscode';
import { md5 } from '@env/crypto';
import type { GlCommands } from '../constants.commands';
import type { Container } from '../container';
import type { MarkdownContentMetadata } from '../documents/markdown';
import { getMarkdownHeaderContent } from '../documents/markdown';
import type { GitRepositoryService } from '../git/gitRepositoryService';
import { GitUri } from '../git/gitUri';
import type { AIExplainSource, AISummarizeResult } from '../plus/ai/aiProviderService';
import type { AIExplainSource, AIResultContext, AISummarizeResult } from '../plus/ai/aiProviderService';
import type { AIModel } from '../plus/ai/models/model';
import { getAIResultContext } from '../plus/ai/utils/-webview/ai.utils';
import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker';
import { showMarkdownPreview } from '../system/-webview/markdown';
Expand Down Expand Up @@ -55,23 +57,106 @@ export abstract class ExplainCommandBase extends GlCommandBase {
return svc;
}

/**
* Opens a document immediately with loading state, then updates it when AI content is ready
*/
protected openDocument(
result: AISummarizeResult,
aiPromise: Promise<AISummarizeResult | 'cancelled' | undefined>,
path: string,
model: AIModel,
metadata: Omit<MarkdownContentMetadata, 'context'>,
): void {
const metadataWithContext: MarkdownContentMetadata = { ...metadata, context: getAIResultContext(result) };
// Create a placeholder AI context for the loading state
const loadingContext: AIResultContext = {
id: `loading-${md5(path)}`,
type: 'explain-changes',
model: model,
};

const metadataWithContext: MarkdownContentMetadata = { ...metadata, context: loadingContext };
const headerContent = getMarkdownHeaderContent(metadataWithContext, this.container.telemetry.enabled);
const content = `${headerContent}\n\n${result.parsed.summary}\n\n${result.parsed.body}`;
const loadingContent = `${headerContent}\n\n---\n\n🤖 **Generating explanation...**\n\nPlease wait while the AI analyzes the changes and generates an explanation. This document will update automatically when the content is ready.\n\n*This may take a few moments depending on the complexity of the changes.*`;

// Open the document immediately with loading content
const documentUri = this.container.markdown.openDocument(
content,
loadingContent,
path,
metadata.header.title,
metadataWithContext,
);

showMarkdownPreview(documentUri);

// Update the document when AI content is ready
void this.updateDocumentWhenReady(documentUri, aiPromise, metadataWithContext);
}

/**
* Updates the document content when AI generation completes
*/
private async updateDocumentWhenReady(
documentUri: Uri,
aiPromise: Promise<AISummarizeResult | 'cancelled' | undefined>,
metadata: MarkdownContentMetadata,
): Promise<void> {
try {
const result = await aiPromise;

if (result === 'cancelled') {
// Update with cancellation message
const cancelledContent = this.createCancelledContent(metadata);
this.container.markdown.updateDocument(documentUri, cancelledContent);
return;
}

if (result == null) {
// Update with error message
const errorContent = this.createErrorContent(metadata);
this.container.markdown.updateDocument(documentUri, errorContent);
return;
}

// Update with successful AI content
this.updateDocumentWithResult(documentUri, result, metadata);
} catch (_error) {
// Update with error message
const errorContent = this.createErrorContent(metadata);
this.container.markdown.updateDocument(documentUri, errorContent);
}
}

/**
* Updates the document with successful AI result
*/
private updateDocumentWithResult(
documentUri: Uri,
result: AISummarizeResult,
metadata: MarkdownContentMetadata,
): void {
const context = getAIResultContext(result);
const metadataWithContext: MarkdownContentMetadata = { ...metadata, context: context };
const headerContent = getMarkdownHeaderContent(metadataWithContext, this.container.telemetry.enabled);
const content = `${headerContent}\n\n${result.parsed.summary}\n\n${result.parsed.body}`;

// Store the AI result context in the feedback provider for documents that cannot store it in their URI
this.container.aiFeedback.setMarkdownDocument(documentUri.toString(), context);

this.container.markdown.updateDocument(documentUri, content);
}

/**
* Creates content for cancelled AI generation
*/
private createCancelledContent(metadata: MarkdownContentMetadata): string {
const headerContent = getMarkdownHeaderContent(metadata, this.container.telemetry.enabled);
return `${headerContent}\n\n---\n\n⚠️ **Generation Cancelled**\n\nThe AI explanation was cancelled before completion.`;
}

/**
* Creates content for failed AI generation
*/
private createErrorContent(metadata: MarkdownContentMetadata): string {
const headerContent = getMarkdownHeaderContent(metadata, this.container.telemetry.enabled);
return `${headerContent}\n\n---\n\n❌ **Generation Failed**\n\nUnable to generate an explanation for the changes. Please try again.`;
}
}
7 changes: 6 additions & 1 deletion src/commands/explainBranch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,12 @@ export class ExplainBranchCommand extends ExplainCommandBase {
return;
}

this.openDocument(result, `/explain/branch/${branch.ref}/${result.model.id}`, {
const {
aiPromise,
info: { model },
} = result;

this.openDocument(aiPromise, `/explain/branch/${branch.ref}/${model.id}`, model, {
header: { title: 'Branch Summary', subtitle: branch.name },
command: {
label: 'Explain Branch Changes',
Expand Down
7 changes: 6 additions & 1 deletion src/commands/explainCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ export class ExplainCommitCommand extends ExplainCommandBase {
return;
}

this.openDocument(result, `/explain/commit/${commit.ref}/${result.model.id}`, {
const {
aiPromise,
info: { model },
} = result;

this.openDocument(aiPromise, `/explain/commit/${commit.ref}/${model.id}`, model, {
header: { title: 'Commit Summary', subtitle: `${commit.summary} (${commit.shortSha})` },
command: {
label: 'Explain Commit Summary',
Expand Down
9 changes: 7 additions & 2 deletions src/commands/explainStash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ export class ExplainStashCommand extends ExplainCommandBase {
if (result === 'cancelled') return;

if (result == null) {
void showGenericErrorMessage('No changes found to explain for stash');
void showGenericErrorMessage('Unable to explain stash');
return;
}

this.openDocument(result, `/explain/stash/${commit.ref}/${result.model.id}`, {
const {
aiPromise,
info: { model },
} = result;

this.openDocument(aiPromise, `/explain/stash/${commit.ref}/${model.id}`, model, {
header: { title: 'Stash Summary', subtitle: commit.message || commit.ref },
command: {
label: 'Explain Stash Changes',
Expand Down
7 changes: 6 additions & 1 deletion src/commands/explainWip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ export class ExplainWipCommand extends ExplainCommandBase {
return;
}

this.openDocument(result, `/explain/wip/${svc.path}/${result.model.id}`, {
const {
aiPromise,
info: { model },
} = result;

this.openDocument(aiPromise, `/explain/wip/${svc.path}/${model.id}`, model, {
header: {
title: `${capitalize(label)} Changes Summary`,
subtitle: `${capitalize(label)} Changes (${repoName})`,
Expand Down
46 changes: 44 additions & 2 deletions src/documents/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Disposable, Event, TextDocumentContentProvider } from 'vscode';
import { EventEmitter, Uri, workspace } from 'vscode';
import type { Event, TabChangeEvent, TextDocumentContentProvider } from 'vscode';
import { Disposable, EventEmitter, TabInputCustom, Uri, window, workspace } from 'vscode';
import { Schemes } from '../constants';
import type { GlCommands } from '../constants.commands';
import type { Container } from '../container';
Expand All @@ -17,6 +17,7 @@ export interface MarkdownContentMetadata {
export class MarkdownContentProvider implements TextDocumentContentProvider {
private contents = new Map<string, string>();
private registration: Disposable;
private visibilityTracker: Disposable;

private _onDidChange = new EventEmitter<Uri>();
get onDidChange(): Event<Uri> {
Expand All @@ -26,6 +27,13 @@ export class MarkdownContentProvider implements TextDocumentContentProvider {
constructor(private container: Container) {
this.registration = workspace.registerTextDocumentContentProvider(Schemes.GitLensAIMarkdown, this);

// Track tab changes to detect when content needs recovery
this.visibilityTracker = Disposable.from(
window.tabGroups.onDidChangeTabs((e: TabChangeEvent) => {
this.onTabsChanged(e);
}),
);

workspace.onDidCloseTextDocument(document => {
if (document.uri.scheme === Schemes.GitLensAIMarkdown) {
this.contents.delete(document.uri.toString());
Expand Down Expand Up @@ -71,13 +79,47 @@ export class MarkdownContentProvider implements TextDocumentContentProvider {
this._onDidChange.fire(uri);
}

/**
* Forces content recovery for a document - useful when content gets corrupted
*/
forceContentRecovery(uri: Uri): void {
const uriString = uri.toString();
if (!this.contents.has(uriString)) return;

const storedContent = this.contents.get(uriString);
if (!storedContent) return;

// I'm deleting the content because if I just fire the change once to make VSCode
// reach our `provideTextDocumentContent` method
// and `provideTextDocumentContent` returns the unchanged conent,
// VSCode will not refresh the content, instead it keeps displaying the original conetnt
// that the view had when it was opened initially.
// That's why I need to blink the content.
if (storedContent.at(storedContent.length - 1) === '\n') {
this.contents.set(uriString, storedContent.substring(0, storedContent.length - 1));
} else {
this.contents.set(uriString, `${storedContent}\n`);
}
this._onDidChange.fire(uri);
}

closeDocument(uri: Uri): void {
this.contents.delete(uri.toString());
}

dispose(): void {
this.contents.clear();
this.registration.dispose();
this.visibilityTracker.dispose();
}

private onTabsChanged(e: TabChangeEvent) {
for (const tab of e.changed) {
if (tab.input instanceof TabInputCustom && tab.input.uri.scheme === Schemes.GitLensAIMarkdown) {
const uri = tab.input.uri;
this.forceContentRecovery(uri);
}
}
}
}

Expand Down
Loading