diff --git a/CHANGELOG.md b/CHANGELOG.md index f15918aee9544..4ca294c014915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Changed - Changes branch favoriting to apply to both local and remote branch pairs ([#4497](https://github.com/gitkraken/vscode-gitlens/issues/4497)) +- Supports opening an explain summary document before summary content is generated ([#4328](https://github.com/gitkraken/vscode-gitlens/issues/4328)) ## [17.3.3] - 2025-07-28 diff --git a/src/commands/aiFeedback.ts b/src/commands/aiFeedback.ts index 8e88489cb10ad..5e1677e45a3bf 100644 --- a/src/commands/aiFeedback.ts +++ b/src/commands/aiFeedback.ts @@ -1,5 +1,5 @@ -import type { TextEditor, Uri } from 'vscode'; -import { window } from 'vscode'; +import type { Disposable, TextEditor, Uri } from 'vscode'; +import { window, workspace } from 'vscode'; import type { AIFeedbackEvent, AIFeedbackUnhelpfulReasons, Source } from '../constants.telemetry'; import type { Container } from '../container'; import type { AIResultContext } from '../plus/ai/aiProviderService'; @@ -12,6 +12,7 @@ import type { Deferrable } from '../system/function/debounce'; import { debounce } from '../system/function/debounce'; import { filterMap, map } from '../system/iterable'; import { Logger } from '../system/logger'; +import { createDisposable } from '../system/unifiedDisposable'; import { ActiveEditorCommand } from './commandBase'; import { getCommandUri } from './commandBase.utils'; @@ -45,6 +46,31 @@ export class AIFeedbackUnhelpfulCommand extends ActiveEditorCommand { type UnhelpfulResult = { reasons?: AIFeedbackUnhelpfulReasons[]; custom?: string }; +let _documentCloseTracker: Disposable | undefined; +const _markdownDocuments = new Map(); +export function getMarkdownDocument(documentUri: string): AIResultContext | undefined { + return _markdownDocuments.get(documentUri); +} +export function setMarkdownDocument(documentUri: string, context: AIResultContext, container: Container): void { + _markdownDocuments.set(documentUri, context); + + if (!_documentCloseTracker) { + _documentCloseTracker = workspace.onDidCloseTextDocument(document => { + deleteMarkdownDocument(document.uri.toString()); + }); + container.context.subscriptions.push( + createDisposable(() => { + _documentCloseTracker?.dispose(); + _documentCloseTracker = undefined; + _markdownDocuments.clear(); + }), + ); + } +} +function deleteMarkdownDocument(documentUri: string): void { + _markdownDocuments.delete(documentUri); +} + const uriResponses = new UriMap(); let _updateContextDebounced: Deferrable<() => void> | undefined; diff --git a/src/commands/explainBase.ts b/src/commands/explainBase.ts index eb78201a1ad30..927a877194d62 100644 --- a/src/commands/explainBase.ts +++ b/src/commands/explainBase.ts @@ -1,14 +1,17 @@ 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'; +import { setMarkdownDocument } from './aiFeedback'; import { GlCommandBase } from './commandBase'; import { getCommandUri } from './commandBase.utils'; @@ -55,23 +58,108 @@ 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, path: string, + model: AIModel, + feature: string, metadata: Omit, ): 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', + feature: feature, + 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> 🤖 **Generating explanation...**\n> Please 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, + metadata: MarkdownContentMetadata, + ): Promise { + 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 + setMarkdownDocument(documentUri.toString(), context, this.container); + + 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.`; } } diff --git a/src/commands/explainBranch.ts b/src/commands/explainBranch.ts index d504534ae7674..1193691d324c5 100644 --- a/src/commands/explainBranch.ts +++ b/src/commands/explainBranch.ts @@ -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, 'explain-branch', { header: { title: 'Branch Summary', subtitle: branch.name }, command: { label: 'Explain Branch Changes', diff --git a/src/commands/explainCommit.ts b/src/commands/explainCommit.ts index eeacc9fec5302..a753be0feb7fb 100644 --- a/src/commands/explainCommit.ts +++ b/src/commands/explainCommit.ts @@ -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, 'explain-commit', { header: { title: 'Commit Summary', subtitle: `${commit.summary} (${commit.shortSha})` }, command: { label: 'Explain Commit Summary', diff --git a/src/commands/explainStash.ts b/src/commands/explainStash.ts index 50c26786cf679..435df0121c598 100644 --- a/src/commands/explainStash.ts +++ b/src/commands/explainStash.ts @@ -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, 'explain-stash', { header: { title: 'Stash Summary', subtitle: commit.message || commit.ref }, command: { label: 'Explain Stash Changes', diff --git a/src/commands/explainWip.ts b/src/commands/explainWip.ts index 6925d2d6a51ab..81c095d459ea4 100644 --- a/src/commands/explainWip.ts +++ b/src/commands/explainWip.ts @@ -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, 'explain-wip', { header: { title: `${capitalize(label)} Changes Summary`, subtitle: `${capitalize(label)} Changes (${repoName})`, diff --git a/src/documents/markdown.ts b/src/documents/markdown.ts index 188f87df1b927..578307b96ecbb 100644 --- a/src/documents/markdown.ts +++ b/src/documents/markdown.ts @@ -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'; @@ -17,6 +17,7 @@ export interface MarkdownContentMetadata { export class MarkdownContentProvider implements TextDocumentContentProvider { private contents = new Map(); private registration: Disposable; + private visibilityTracker: Disposable; private _onDidChange = new EventEmitter(); get onDidChange(): Event { @@ -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()); @@ -71,6 +79,30 @@ 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()); } @@ -78,6 +110,16 @@ export class MarkdownContentProvider implements TextDocumentContentProvider { 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); + } + } } } diff --git a/src/plus/ai/aiProviderService.ts b/src/plus/ai/aiProviderService.ts index 064569bb5cdb4..8f9ad46530a89 100644 --- a/src/plus/ai/aiProviderService.ts +++ b/src/plus/ai/aiProviderService.ts @@ -566,7 +566,11 @@ export class AIProviderService implements Disposable { commitOrRevision: GitRevisionReference | GitCommit, sourceContext: AIExplainSource, options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, - ): Promise { + ): Promise< + | undefined + | 'cancelled' + | { aiPromise: Promise; info: { model: AIModel } } + > { const svc = this.container.git.getRepositoryService(commitOrRevision.repoPath); return this.explainChanges( async cancellation => { @@ -599,10 +603,14 @@ export class AIProviderService implements Disposable { | ((cancellationToken: CancellationToken) => Promise>), sourceContext: AIExplainSource, options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, - ): Promise { + ): Promise< + | undefined + | 'cancelled' + | { aiPromise: Promise; info: { model: AIModel } } + > { const { type, ...source } = sourceContext; - const result = await this.sendRequest( + const complexResult = await this.sendRequestAndGetPartialRequestInfo( 'explain-changes', async (model, reporting, cancellation, maxInputTokens, retries) => { if (typeof promptContext === 'function') { @@ -645,16 +653,27 @@ export class AIProviderService implements Disposable { }), options, ); - return result === 'cancelled' - ? result - : result != null - ? { - ...result, - type: 'explain-changes', - feature: `explain-${type}`, - parsed: parseSummarizeResult(result.content), - } - : undefined; + + if (complexResult === 'cancelled') return complexResult; + if (complexResult == null) return undefined; + + const aiPromise: Promise = complexResult.aiPromise.then(result => + result === 'cancelled' + ? result + : result != null + ? { + ...result, + type: 'explain-changes', + feature: `explain-${type}`, + parsed: parseSummarizeResult(result.content), + } + : undefined, + ); + + return { + aiPromise: aiPromise, + info: complexResult.info, + }; } async generateCommitMessage( @@ -1422,6 +1441,56 @@ export class AIProviderService implements Disposable { } } + private async sendRequestAndGetPartialRequestInfo( + action: T, + getMessages: ( + model: AIModel, + reporting: TelemetryEvents['ai/generate' | 'ai/explain'], + cancellation: CancellationToken, + maxCodeCharacters: number, + retries: number, + ) => Promise, + getProgressTitle: (model: AIModel) => string, + source: Source, + getTelemetryInfo: (model: AIModel) => { + key: 'ai/generate' | 'ai/explain'; + data: TelemetryEvents['ai/generate' | 'ai/explain']; + }, + options?: { + cancellation?: CancellationToken; + generating?: Deferred; + modelOptions?: { outputTokens?: number; temperature?: number }; + progress?: ProgressOptions; + }, + ): Promise< + | undefined + | 'cancelled' + | { + aiPromise: Promise; + info: { model: AIModel }; + } + > { + if (!(await this.ensureFeatureAccess(action, source))) { + return 'cancelled'; + } + const model = await this.getModel(undefined, source); + if (model == null || options?.cancellation?.isCancellationRequested) { + options?.generating?.cancel(); + return undefined; + } + + const aiPromise = this.sendRequestWithModel( + model, + action, + getMessages, + getProgressTitle, + source, + getTelemetryInfo, + options, + ); + return { aiPromise: aiPromise, info: { model: model } }; + } + private async sendRequest( action: T, getMessages: ( @@ -1449,6 +1518,44 @@ export class AIProviderService implements Disposable { } const model = await this.getModel(undefined, source); + return this.sendRequestWithModel( + model, + action, + getMessages, + getProgressTitle, + source, + getTelemetryInfo, + options, + ); + } + + private async sendRequestWithModel( + model: AIModel | undefined, + action: T, + getMessages: ( + model: AIModel, + reporting: TelemetryEvents['ai/generate' | 'ai/explain'], + cancellation: CancellationToken, + maxCodeCharacters: number, + retries: number, + ) => Promise, + getProgressTitle: (model: AIModel) => string, + source: Source, + getTelemetryInfo: (model: AIModel) => { + key: 'ai/generate' | 'ai/explain'; + data: TelemetryEvents['ai/generate' | 'ai/explain']; + }, + options?: { + cancellation?: CancellationToken; + generating?: Deferred; + modelOptions?: { outputTokens?: number; temperature?: number }; + progress?: ProgressOptions; + }, + ): Promise { + if (!(await this.ensureFeatureAccess(action, source))) { + return 'cancelled'; + } + if (options?.cancellation?.isCancellationRequested) { options?.generating?.cancel(); return 'cancelled'; diff --git a/src/plus/ai/utils/-webview/ai.utils.ts b/src/plus/ai/utils/-webview/ai.utils.ts index 339b7538410df..0c5e7e551acd9 100644 --- a/src/plus/ai/utils/-webview/ai.utils.ts +++ b/src/plus/ai/utils/-webview/ai.utils.ts @@ -1,5 +1,6 @@ import type { Disposable, QuickInputButton } from 'vscode'; import { env, ThemeIcon, Uri, window } from 'vscode'; +import { getMarkdownDocument } from '../../../../commands/aiFeedback'; import { Schemes } from '../../../../constants'; import type { AIProviders } from '../../../../constants.ai'; import type { Container } from '../../../../container'; @@ -289,8 +290,9 @@ export function extractAIResultContext(uri: Uri | undefined): AIResultContext | if (!authority) return undefined; try { + const context: AIResultContext | undefined = getMarkdownDocument(uri.toString()); const metadata = decodeGitLensRevisionUriAuthority(authority); - return metadata.context; + return context ?? metadata.context; } catch (ex) { Logger.error(ex, 'extractResultContext'); return undefined; diff --git a/src/webviews/plus/patchDetails/patchDetailsWebview.ts b/src/webviews/plus/patchDetails/patchDetailsWebview.ts index c042414b3ec9b..004b159ea2559 100644 --- a/src/webviews/plus/patchDetails/patchDetailsWebview.ts +++ b/src/webviews/plus/patchDetails/patchDetailsWebview.ts @@ -841,7 +841,14 @@ export class PatchDetailsWebviewProvider if (result == null) throw new Error('Error retrieving content'); - params = { result: result.parsed }; + const { aiPromise } = result; + + const aiResult = await aiPromise; + if (aiResult === 'cancelled') throw new Error('Operation was canceled'); + + if (aiResult == null) throw new Error('Error retrieving content'); + + params = { result: aiResult.parsed }; } catch (ex) { debugger; params = { error: { message: ex.message } };