diff --git a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts index 34a1289465565..61a42ed28fc96 100644 --- a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @@ -6,8 +6,9 @@ import * as dom from 'vs/base/browser/dom'; import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { AriaRole } from 'vs/base/browser/ui/aria/aria'; +import { Button } from 'vs/base/browser/ui/button/button'; import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels'; -import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import { ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree'; import { ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; @@ -51,7 +52,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { FileKind } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; -import { WorkbenchCompressibleAsyncDataTree } from 'vs/platform/list/browser/listService'; +import { WorkbenchCompressibleAsyncDataTree, WorkbenchList } from 'vs/platform/list/browser/listService'; import { ILogService } from 'vs/platform/log/common/log'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; @@ -66,7 +67,7 @@ import { convertParsedRequestToMarkdown, walkTreeAndAnnotateResourceLinks } from import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions'; import { CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_HAS_PROVIDER_ID, CONTEXT_RESPONSE_VOTE } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { IPlaceholderMarkdownString } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IChatReplyFollowup, IChatResponseProgressFileTreeData, IChatService, ISlashCommand, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatReplyFollowup, IChatResponseProgressFileTreeData, IChatService, IDocumentContext, ISlashCommand, IUsedContext, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatResponseMarkdownRenderData, IChatResponseRenderData, IChatResponseViewModel, IChatWelcomeMessageViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; import { IWordCountResult, getNWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer'; @@ -74,6 +75,7 @@ import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEdito import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; import { createFileIconThemableTreeContainerScope } from 'vs/workbench/contrib/files/browser/views/explorerView'; import { IFilesConfiguration } from 'vs/workbench/contrib/files/common/files'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; const $ = dom.$; @@ -123,6 +125,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this._onDidClickFollowup.fire(followup), templateData.contextKeyService)); } else { - const result = this.renderMarkdown(item as IMarkdownString, element, templateData.elementDisposables, templateData); + const result = this.renderMarkdown(item as IMarkdownString, element, templateData); for (const codeElement of result.element.querySelectorAll('code')) { if (codeElement.textContent && slashCommands.find(command => codeElement.textContent === `/${command.command}`)) { codeElement.classList.add('interactive-slash-command'); @@ -487,7 +498,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer void } { - const ref = this._treePool.get(); + private renderTreeData(data: IChatResponseProgressFileTreeData, element: ChatTreeItem, templateData: IChatListItemTemplate, treeDataIndex: number): { element: HTMLElement; dispose: () => void } { + const treeDisposables = new DisposableStore(); + const ref = treeDisposables.add(this._treePool.get()); const tree = ref.object; - const treeDisposables = new DisposableStore(); treeDisposables.add(tree.onDidOpen((e) => { if (e.element && !('children' in e.element)) { this.openerService.open(e.element.uri); @@ -571,14 +582,82 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer v.treeDataId)); - disposables.add(toDisposable(() => this.fileTreesByResponseId.set(element.id, fileTrees.filter(v => v.treeDataId !== data.uri.toString())))); + treeDisposables.add(toDisposable(() => this.fileTreesByResponseId.set(element.id, fileTrees.filter(v => v.treeDataId !== data.uri.toString())))); } return { element: tree.getHTMLElement().parentElement!, dispose: () => { treeDisposables.dispose(); - ref.dispose(); + } + }; + } + + private renderUsedContextListData(data: IUsedContext, element: IChatResponseViewModel, templateData: IChatListItemTemplate): { element: HTMLElement; dispose: () => void } { + const listDisposables = new DisposableStore(); + const referencesLabel = data.documents.length > 1 ? + localize('usedReferencesPlural', "Used {0} references", data.documents.length) : + localize('usedReferencesSingular', "Used {0} reference", 1); + const iconElement = $('.chat-used-context-icon'); + const icon = (element: IChatResponseViewModel) => element.usedReferencesExpanded ? Codicon.chevronDown : Codicon.chevronRight; + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon(element))); + const buttonElement = $('.chat-used-context-label', undefined); + + const collapseButton = new Button(buttonElement, { + buttonBackground: undefined, + buttonBorder: undefined, + buttonForeground: undefined, + buttonHoverBackground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined + }); + const container = $('.chat-used-context', undefined, buttonElement); + collapseButton.label = referencesLabel; + collapseButton.element.prepend(iconElement); + + container.classList.toggle('chat-used-context-collapsed', !element.usedReferencesExpanded); + listDisposables.add(collapseButton.onDidClick(() => { + iconElement.classList.remove(...ThemeIcon.asClassNameArray(icon(element))); + element.usedReferencesExpanded = !element.usedReferencesExpanded; + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon(element))); + container.classList.toggle('chat-used-context-collapsed', !element.usedReferencesExpanded); + this._onDidChangeItemHeight.fire({ element, height: templateData.rowContainer.offsetHeight }); + })); + + const ref = listDisposables.add(this._usedContextListPool.get()); + const list = ref.object; + container.appendChild(list.getHTMLElement().parentElement!); + + listDisposables.add(list.onDidOpen((e) => { + if (e.element) { + this.editorService.openEditor({ + resource: e.element.uri, + options: { + ...e.editorOptions, + ...{ + selection: e.element.ranges[0] + } + } + }); + } + })); + listDisposables.add(list.onContextMenu((e) => { + e.browserEvent.preventDefault(); + e.browserEvent.stopPropagation(); + })); + + list.layout(data.documents.length * 22); + list.splice(0, list.length, data.documents); + dom.scheduleAtNextAnimationFrame(() => { + this._onDidChangeItemHeight.fire({ element, height: templateData.rowContainer.offsetHeight }); + }); + + return { + element: container, + dispose: () => { + listDisposables.dispose(); } }; } @@ -595,8 +674,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer content.dispose() }; } - private renderMarkdown(markdown: IMarkdownString, element: ChatTreeItem, disposables: DisposableStore, templateData: IChatListItemTemplate, fillInIncompleteTokens = false): IMarkdownRenderResult { - const disposablesList: IDisposable[] = []; + private renderMarkdown(markdown: IMarkdownString, element: ChatTreeItem, templateData: IChatListItemTemplate, fillInIncompleteTokens = false): IMarkdownRenderResult { + const disposables = new DisposableStore(); let codeBlockIndex = 0; // TODO if the slash commands stay completely dynamic, this isn't quite right @@ -610,6 +689,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.codeBlocksByEditorUri.delete(ref.object.textModel.uri))); } - disposablesList.push(ref); + orderedDisposablesList.push(ref); return ref.object.element; } }); @@ -659,8 +740,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer disposables.add(d)); - return result; + orderedDisposablesList.reverse().forEach(d => disposables.add(d)); + return { + element: result.element, + dispose() { + result.dispose(); + disposables.dispose(); + } + }; } private renderCodeBlock(data: IChatResultCodeBlockData, disposables: DisposableStore): IDisposableReference { @@ -1126,7 +1213,93 @@ class TreePool extends Disposable { } } -// TODO does something in lifecycle.ts cover this? +class UsedContextListPool extends Disposable { + private _pool: ResourcePool>; + + public get inUse(): ReadonlySet> { + return this._pool.inUse; + } + + constructor( + private _onDidChangeVisibility: Event, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configService: IConfigurationService, + @IThemeService private readonly themeService: IThemeService, + ) { + super(); + this._pool = this._register(new ResourcePool(() => this.listFactory())); + } + + private listFactory(): WorkbenchList { + const resourceLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility }); + + const container = $('.chat-used-context-list'); + createFileIconThemableTreeContainerScope(container, this.themeService); + + const list = >this.instantiationService.createInstance( + WorkbenchList, + 'ChatListRenderer', + container, + new UsedContextListDelegate(), + [new UsedContextListRenderer(resourceLabels, this.configService.getValue('explorer.decorations'))], + {}); + + return list; + } + + get(): IDisposableReference> { + const object = this._pool.get(); + let stale = false; + return { + object, + isStale: () => stale, + dispose: () => { + stale = true; + this._pool.release(object); + } + }; + } +} + +class UsedContextListDelegate implements IListVirtualDelegate { + getHeight(element: IDocumentContext): number { + return 22; + } + + getTemplateId(element: IDocumentContext): string { + return UsedContextListRenderer.TEMPLATE_ID; + } +} + +interface IUsedContextListTemplate { + label: IResourceLabel; + templateDisposables: IDisposable; +} + +class UsedContextListRenderer implements IListRenderer { + static TEMPLATE_ID = 'usedContextListRenderer'; + readonly templateId: string = UsedContextListRenderer.TEMPLATE_ID; + + constructor(private labels: ResourceLabels, private decorations: IFilesConfiguration['explorer']['decorations']) { } + + renderTemplate(container: HTMLElement): IUsedContextListTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true })); + return { templateDisposables, label }; + } + + renderElement(element: IDocumentContext, index: number, templateData: IUsedContextListTemplate, height: number | undefined): void { + templateData.label.element.style.display = 'flex'; + templateData.label.setFile(element.uri, { + fileKind: FileKind.FILE, + fileDecorations: this.decorations, + }); + } + + disposeTemplate(templateData: IUsedContextListTemplate): void { + templateData.templateDisposables.dispose(); + } +} class ResourcePool extends Disposable { private readonly pool: T[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 3b5949a64cbe3..7052198e920a8 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -95,27 +95,27 @@ width: 100%; } -.interactive-item-container .value table { +.interactive-item-container .value .rendered-markdown table { width: 100%; text-align: left; margin-bottom: 16px; } -.interactive-item-container .value table, -.interactive-item-container .value table td, -.interactive-item-container .value table th { +.interactive-item-container .value .rendered-markdown table, +.interactive-item-container .value .rendered-markdown table td, +.interactive-item-container .value .rendered-markdown table th { border: 1px solid var(--vscode-chat-requestBorder); border-collapse: collapse; padding: 4px 6px; } -.interactive-item-container .value a, -.interactive-item-container .value a code { +.interactive-item-container .value .rendered-markdown a, +.interactive-item-container .value .rendered-markdown a code { color: var(--vscode-textLink-foreground); } -.interactive-item-container .value a:hover, -.interactive-item-container .value a:active { +.interactive-item-container .value .rendered-markdown a:hover, +.interactive-item-container .value .rendered-markdown a:active { color: var(--vscode-textLink-activeForeground); } @@ -144,31 +144,31 @@ margin-bottom: 8px; } -.interactive-item-container .value h1 { +.interactive-item-container .value .rendered-markdown h1 { font-size: 20px; font-weight: 600; margin: 16px 0; } -.interactive-item-container .value h2 { +.interactive-item-container .value .rendered-markdown h2 { font-size: 16px; font-weight: 600; margin: 16px 0; } -.interactive-item-container .value h3 { +.interactive-item-container .value .rendered-markdown h3 { font-size: 14px; font-weight: 600; margin: 16px 0; } -.interactive-item-container .value p { +.interactive-item-container .value .rendered-markdown p { margin: 0 0 16px 0; line-height: 1.6em; } -.interactive-item-container .value li { +.interactive-item-container .value .rendered-markdown li { line-height: 1.5rem; } @@ -200,24 +200,24 @@ min-height: 0; } -.interactive-item-container.interactive-item-compact .value p { +.interactive-item-container.interactive-item-compact .value .rendered-markdown p { margin: 0 0 8px 0; } -.interactive-item-container.interactive-item-compact .value h1 { +.interactive-item-container.interactive-item-compact .value .rendered-markdown h1 { margin: 8px 0; } -.interactive-item-container.interactive-item-compact .value h2 { +.interactive-item-container.interactive-item-compact .value .rendered-markdown h2 { margin: 8px 0; } -.interactive-item-container.interactive-item-compact .value h3 { +.interactive-item-container.interactive-item-compact .value .rendered-markdown h3 { margin: 8px 0; } -.interactive-item-container.interactive-item-compact .value p { +.interactive-item-container.interactive-item-compact .value .rendered-markdown p { margin: 0 0 8px 0; } @@ -464,7 +464,8 @@ gap: 6px; } -.interactive-response-progress-tree .monaco-list { +.interactive-response-progress-tree .monaco-list, +.chat-used-context-list .monaco-list { border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; width: auto; @@ -477,3 +478,23 @@ white-space: nowrap; padding: 1px; } + +.interactive-session .chat-used-context.chat-used-context-collapsed .chat-used-context-list { + display: none; +} + +.interactive-session .chat-used-context-label { + font-size: 0.9em; +} + +.interactive-session .chat-used-context-label .monaco-button { + /* unset Button styles */ + display: inline-flex; + width: initial; + border: none; + padding: 0; + text-align: initial; + padding-left: 4px; + justify-content: initial; + margin-bottom: 3px; +} diff --git a/src/vs/workbench/contrib/chat/common/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/chatViewModel.ts index 2238e4d949f46..87d005c5ec46f 100644 --- a/src/vs/workbench/contrib/chat/common/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatViewModel.ts @@ -95,6 +95,7 @@ export interface IChatResponseViewModel { renderData?: IChatResponseRenderData; currentRenderedHeight: number | undefined; setVote(vote: InteractiveSessionVoteDirection): void; + usedReferencesExpanded?: boolean; } export class ChatViewModel extends Disposable implements IChatViewModel { @@ -299,6 +300,8 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi currentRenderedHeight: number | undefined; + usedReferencesExpanded?: boolean | undefined; + private _contentUpdateTimings: IChatLiveUpdateData | undefined = undefined; get contentUpdateTimings(): IChatLiveUpdateData | undefined { return this._contentUpdateTimings;