diff --git a/src/extension/log/vscode-node/requestLogTree.ts b/src/extension/log/vscode-node/requestLogTree.ts index e846aa66fc..57cbb44320 100644 --- a/src/extension/log/vscode-node/requestLogTree.ts +++ b/src/extension/log/vscode-node/requestLogTree.ts @@ -14,6 +14,7 @@ import { IVSCodeExtensionContext } from '../../../platform/extContext/common/ext import { OutputChannelName } from '../../../platform/log/vscode/outputChannelLogTarget'; import { ChatRequestScheme, ILoggedElementInfo, ILoggedRequestInfo, ILoggedToolCall, IRequestLogger, LoggedInfo, LoggedInfoKind, LoggedRequestKind } from '../../../platform/requestLogger/node/requestLogger'; import { assertNever } from '../../../util/vs/base/common/assert'; +import { RunOnceScheduler } from '../../../util/vs/base/common/async'; import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle'; import { LRUCache } from '../../../util/vs/base/common/map'; import { isDefined } from '../../../util/vs/base/common/types'; @@ -32,6 +33,7 @@ const showRawRequestBodyCommand = 'github.copilot.chat.debug.showRawRequestBody' export class RequestLogTree extends Disposable implements IExtensionContribution { readonly id = 'requestLogTree'; private readonly chatRequestProvider: ChatRequestProvider; + private readonly treeView: vscode.TreeView; constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -39,7 +41,9 @@ export class RequestLogTree extends Disposable implements IExtensionContribution ) { super(); this.chatRequestProvider = this._register(instantiationService.createInstance(ChatRequestProvider)); - this._register(vscode.window.registerTreeDataProvider('copilot-chat', this.chatRequestProvider)); + this.treeView = this._register(vscode.window.createTreeView('copilot-chat', { treeDataProvider: this.chatRequestProvider })); + this.chatRequestProvider.setVisible(this.treeView.visible); + this._register(this.treeView.onDidChangeVisibility(e => this.chatRequestProvider.setVisible(e.visible))); let server: RequestServer | undefined; @@ -559,6 +563,16 @@ type TreeItem = ChatPromptItem | ChatRequestItem | ChatElementItem | ToolCallIte class ChatRequestProvider extends Disposable implements vscode.TreeDataProvider { private readonly filters: LogTreeFilters; + private rootItems: (ChatPromptItem | TreeChildItem)[] = []; + private seenChatRequests = new WeakSet(); + private processedCount = 0; + private currentPrompt: ChatPromptItem | undefined; + private readonly refreshScheduler: RunOnceScheduler; + private pendingRootRefresh = false; + private readonly pendingPromptRefresh = new Set(); + private readonly refreshDelay = 50; + private isVisible = false; + private needsRefreshWhenVisible = false; constructor( @IRequestLogger private readonly requestLogger: IRequestLogger, @@ -567,8 +581,10 @@ class ChatRequestProvider extends Disposable implements vscode.TreeDataProvider< super(); this.filters = this._register(instantiationService.createInstance(LogTreeFilters)); this._register(new LogTreeFilterCommands(this.filters)); - this._register(this.requestLogger.onDidChangeRequests(() => this._onDidChangeTreeData.fire())); - this._register(this.filters.onDidChangeFilters(() => this._onDidChangeTreeData.fire())); + this._register(this.requestLogger.onDidChangeRequests(() => this.handleRequestChange())); + this._register(this.filters.onDidChangeFilters(() => this.handleFilterChange())); + this.refreshScheduler = this._register(new RunOnceScheduler(() => this.flushRefreshQueue(), 0)); + this.rebuildFromScratch(); } private readonly _onDidChangeTreeData = new vscode.EventEmitter(); @@ -580,48 +596,104 @@ class ChatRequestProvider extends Disposable implements vscode.TreeDataProvider< getChildren(element?: TreeItem | undefined): vscode.ProviderResult { if (element instanceof ChatPromptItem) { - return element.children; - } else if (element) { + return element.children.filter(child => this.filters.itemIncluded(child)); + } + + if (element) { return []; + } + + return this.rootItems.filter(item => this.filters.itemIncluded(item)); + } + + private rebuildFromScratch(): void { + this.rootItems = []; + this.seenChatRequests = new WeakSet(); + this.processedCount = 0; + this.currentPrompt = undefined; + this.appendNewEntries(); + } + + private handleRequestChange(): void { + this.appendNewEntries(); + } + + private handleFilterChange(): void { + if (this.isVisible) { + this._onDidChangeTreeData.fire(undefined); } else { - let lastPrompt: ChatPromptItem | undefined; - const result: (ChatPromptItem | TreeChildItem)[] = []; - const seen = new Set(); - - for (const r of this.requestLogger.getRequests()) { - const item = this.logToTreeItem(r); - if (r.chatRequest !== lastPrompt?.request) { - if (lastPrompt) { - result.push(lastPrompt); - } - lastPrompt = r.chatRequest ? ChatPromptItem.create(r, r.chatRequest, seen.has(r.chatRequest)) : undefined; - seen.add(r.chatRequest); + this.pendingRootRefresh = true; + this.needsRefreshWhenVisible = true; + } + } + + setVisible(visible: boolean): void { + this.isVisible = visible; + if (visible) { + if (this.pendingRootRefresh || this.pendingPromptRefresh.size || this.needsRefreshWhenVisible) { + this.refreshScheduler.schedule(0); + } + } + } + + private appendNewEntries(): void { + const requests = this.requestLogger.getRequests(); + let rootChanged = false; + const promptsToRefresh = new Set(); + const newPrompts = new Set(); + + if (requests.length < this.processedCount) { + this.rootItems = []; + this.seenChatRequests = new WeakSet(); + this.processedCount = 0; + this.currentPrompt = undefined; + rootChanged = true; + } + + for (let i = this.processedCount; i < requests.length; i++) { + const info = requests[i]; + const child = this.logToTreeItem(info); + const request = info.chatRequest; + + if (request) { + const hasSeen = this.seenChatRequests.has(request); + if (!this.currentPrompt || this.currentPrompt.request !== request) { + this.currentPrompt = ChatPromptItem.create(info, request, hasSeen); + this.currentPrompt.children.length = 0; + this.rootItems.push(this.currentPrompt); + this.seenChatRequests.add(request); + rootChanged = true; + newPrompts.add(this.currentPrompt); } - if (lastPrompt) { - if (!lastPrompt.children.find(c => c.id === item.id)) { - lastPrompt.children.push(item); - } - if (!lastPrompt.children.find(c => c.id === item.id)) { - lastPrompt.children.push(item); - } - } else { - result.push(item); + if (!this.currentPrompt.children.some(c => c.id === child.id)) { + this.currentPrompt.children.push(child); + promptsToRefresh.add(this.currentPrompt); } + } else { + this.currentPrompt = undefined; + this.rootItems.push(child); + rootChanged = true; } + } - if (lastPrompt) { - result.push(lastPrompt); - } + this.processedCount = requests.length; - return result.map(r => { - if (r instanceof ChatPromptItem) { - return r.withFilteredChildren(child => this.filters.itemIncluded(child)); - } + if (rootChanged) { + this.pendingRootRefresh = true; + } + + for (const prompt of promptsToRefresh) { + if (rootChanged && newPrompts.has(prompt)) { + continue; + } + this.pendingPromptRefresh.add(prompt); + } - return r; - }) - .filter(r => this.filters.itemIncluded(r)); + if (this.isVisible) { + this.refreshScheduler.schedule(this.refreshDelay); + } else { + this.needsRefreshWhenVisible = true; } } @@ -637,6 +709,24 @@ class ChatRequestProvider extends Disposable implements vscode.TreeDataProvider< assertNever(r); } } + + private flushRefreshQueue(): void { + if (!this.isVisible) { + return; + } + + if (this.pendingRootRefresh) { + this._onDidChangeTreeData.fire(undefined); + } else { + for (const prompt of this.pendingPromptRefresh) { + this._onDidChangeTreeData.fire(prompt); + } + } + + this.pendingRootRefresh = false; + this.pendingPromptRefresh.clear(); + this.needsRefreshWhenVisible = false; + } } type TreeChildItem = ChatRequestItem | ChatElementItem | ToolCallItem; @@ -824,4 +914,4 @@ class LogTreeFilterCommands extends Disposable { this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showNesRequests', () => filters.setNesRequestsShown(true))); this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideNesRequests', () => filters.setNesRequestsShown(false))); } -} \ No newline at end of file +} diff --git a/src/extension/prompt/node/defaultIntentRequestHandler.ts b/src/extension/prompt/node/defaultIntentRequestHandler.ts index 402f0bc760..06f1510b2f 100644 --- a/src/extension/prompt/node/defaultIntentRequestHandler.ts +++ b/src/extension/prompt/node/defaultIntentRequestHandler.ts @@ -5,7 +5,7 @@ import * as l10n from '@vscode/l10n'; import { Raw } from '@vscode/prompt-tsx'; -import type { ChatRequest, ChatResponseReferencePart, ChatResponseStream, ChatResult, LanguageModelToolInformation, Progress } from 'vscode'; +import type { ChatRequest, ChatResponseReferencePart, ChatResponseStream, ChatResult, LanguageModelToolInformation, MarkdownString, Progress } from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; import { ICopilotTokenStore } from '../../../platform/authentication/common/copilotTokenStore'; @@ -21,7 +21,7 @@ import { IRequestLogger } from '../../../platform/requestLogger/node/requestLogg import { ISurveyService } from '../../../platform/survey/common/surveyService'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; -import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl'; +import { ChatResponseStreamImpl, FinalizableChatResponseStream, tryFinalizeResponseStream } from '../../../util/common/chatResponseStreamImpl'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Event } from '../../../util/vs/base/common/event'; @@ -31,7 +31,7 @@ import { mixin } from '../../../util/vs/base/common/objects'; import { assertType, Mutable } from '../../../util/vs/base/common/types'; import { localize } from '../../../util/vs/nls'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseMarkdownPart, ChatResponseProgressPart, ChatResponseTextEditPart, LanguageModelToolResult2 } from '../../../vscodeTypes'; +import { ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseProgressPart, ChatResponseTextEditPart, LanguageModelToolResult2 } from '../../../vscodeTypes'; import { CodeBlocksMetadata, CodeBlockTrackingChatResponseStream } from '../../codeBlocks/node/codeBlockProcessor'; import { CopilotInteractiveEditorResponse, InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes'; import { PauseController } from '../../intents/node/pauseController'; @@ -50,6 +50,7 @@ import { IntentInvocationMetadata } from './conversation'; import { IDocumentContext } from './documentContext'; import { IBuildPromptResult, IIntent, IIntentInvocation, IResponseProcessor } from './intents'; import { ConversationalBaseTelemetryData, createTelemetryWithId, sendModelMessageTelemetry } from './telemetry'; +import { RunOnceScheduler } from '../../../util/vs/base/common/async'; import { isToolCallLimitCancellation } from '../common/specialRequestTypes'; export interface IDefaultIntentRequestHandlerOptions { @@ -246,6 +247,9 @@ export class DefaultIntentRequestHandler { }); } + // 5. Throttle markdown emissions so the renderer doesn't repaint on every token + participants.push(stream => new ThrottledMarkdownStream(stream)); + // 5. General telemetry on emitted components participants.push(stream => ChatResponseStreamImpl.spy(stream, (part) => { if (part instanceof ChatResponseMarkdownPart) { @@ -490,6 +494,177 @@ export class DefaultIntentRequestHandler { } } +class ThrottledMarkdownStream implements FinalizableChatResponseStream { + private readonly flushScheduler: RunOnceScheduler; + private pendingText = ''; + private readonly maxBufferLength = 4000; + + constructor( + private readonly target: ChatResponseStream, + private readonly flushDelay = 80 + ) { + this.flushScheduler = new RunOnceScheduler(() => this.flush(), flushDelay); + } + + async finalize(): Promise { + this.flush(); + this.flushScheduler.dispose(); + await tryFinalizeResponseStream(this.target); + } + + clearToPreviousToolInvocation(reason: any): void { + this.flush(); + this.target.clearToPreviousToolInvocation(reason); + } + + markdown(value: string | MarkdownString): ChatResponseStream { + if (typeof value === 'string') { + this.enqueueText(value); + } else { + this.flush(); + this.target.markdown(value); + } + return this; + } + + markdownWithVulnerabilities(value: string | MarkdownString, vulnerabilities: any): ChatResponseStream { + this.flush(); + this.target.markdownWithVulnerabilities(value, vulnerabilities); + return this; + } + + anchor(value: any, title?: string): ChatResponseStream { + this.flush(); + this.target.anchor(value, title); + return this; + } + + button(command: any): ChatResponseStream { + this.flush(); + this.target.button(command); + return this; + } + + filetree(value: any, baseUri: any): ChatResponseStream { + this.flush(); + this.target.filetree(value, baseUri); + return this; + } + + progress(value: string): ChatResponseStream { + this.flush(); + this.target.progress(value); + return this; + } + + thinkingProgress(thinkingDelta: any): ChatResponseStream { + this.flush(); + this.target.thinkingProgress(thinkingDelta); + return this; + } + + warning(value: string | MarkdownString): ChatResponseStream { + this.flush(); + this.target.warning(value); + return this; + } + + reference(value: any, title?: any): ChatResponseStream { + this.flush(); + this.target.reference(value, title); + return this; + } + + reference2(value: any, title?: any, options?: any): ChatResponseStream { + this.flush(); + if ('reference2' in this.target) { + (this.target as any).reference2(value, title, options); + } else { + this.target.reference(value, title); + } + return this; + } + + codeCitation(value: any, license: any, snippet: any): ChatResponseStream { + this.flush(); + this.target.codeCitation(value, license, snippet); + return this; + } + + push(part: any): ChatResponseStream { + if (part instanceof ChatResponseMarkdownPart) { + this.markdown(part.value); + } else if (part instanceof ChatResponseMarkdownWithVulnerabilitiesPart) { + this.markdownWithVulnerabilities(part.value, part.vulnerabilities); + } else { + this.flush(); + this.target.push(part); + } + return this; + } + + textEdit(target: any, editsOrDone: any): ChatResponseStream { + this.flush(); + this.target.textEdit(target, editsOrDone); + return this; + } + + notebookEdit(target: any, editsOrDone: any): ChatResponseStream { + this.flush(); + if ('notebookEdit' in this.target) { + (this.target as any).notebookEdit(target, editsOrDone); + } + return this; + } + + codeblockUri(value: any, isEdit?: boolean): void { + this.flush(); + if ('codeblockUri' in this.target) { + (this.target as any).codeblockUri(value, isEdit); + } + } + + confirmation(title: string, message: string, data: any, buttons?: string[]): ChatResponseStream { + this.flush(); + if ('confirmation' in this.target) { + (this.target as any).confirmation(title, message, data, buttons); + } + return this; + } + + prepareToolInvocation(toolName: string): ChatResponseStream { + this.flush(); + if ('prepareToolInvocation' in this.target) { + (this.target as any).prepareToolInvocation(toolName); + } + return this; + } + + private enqueueText(text: string) { + if (!text) { + return; + } + + this.pendingText += text; + if (this.pendingText.length >= this.maxBufferLength) { + this.flush(); + } else { + this.flushScheduler.schedule(this.flushDelay); + } + } + + private flush() { + if (!this.pendingText) { + return; + } + + const buffered = this.pendingText; + this.pendingText = ''; + this.target.markdown(buffered); + this.flushScheduler.cancel(); + } +} + interface IInternalRequestResult { response: ChatResponse; round: IToolCallRound;