Skip to content

Commit

Permalink
Merge pull request #210488 from microsoft/roblou/chat-agent-hover
Browse files Browse the repository at this point in the history
Add a nicer hover for chat participants
  • Loading branch information
roblourens authored Apr 19, 2024
2 parents f09f068 + db8ce77 commit 6294089
Show file tree
Hide file tree
Showing 25 changed files with 225 additions and 27 deletions.
2 changes: 1 addition & 1 deletion build/hygiene.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ function createGitIndexVinyls(paths) {

cp.exec(
process.platform === 'win32' ? `git show :${relativePath}` : `git show ':${relativePath}'`,
{ maxBuffer: 2000 * 1024, encoding: 'buffer' },
{ maxBuffer: stat.size, encoding: 'buffer' },
(err, out) => {
if (err) {
return e(err);
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/api/browser/mainThreadChatAgents2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
name: dynamicProps.name,
description: dynamicProps.description,
extensionId: extension,
extensionDisplayName: extensionDescription?.displayName ?? extension.value,
extensionPublisher: extensionDescription?.publisherDisplayName ?? extension.value,
metadata: revive(metadata),
slashCommands: [],
Expand Down
83 changes: 83 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chatAgentHover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as dom from 'vs/base/browser/dom';
import { h } from 'vs/base/browser/dom';
import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels';
import { CancellationToken } from 'vs/base/common/cancellation';
import { FileAccess } from 'vs/base/common/network';
import { ThemeIcon } from 'vs/base/common/themables';
import { URI } from 'vs/base/common/uri';
import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { verifiedPublisherIcon } from 'vs/workbench/contrib/extensions/browser/extensionsIcons';
import { IExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/common/extensions';

export class ChatAgentHover {
public readonly domNode: HTMLElement;

constructor(
id: string,
@IChatAgentService private readonly chatAgentService: IChatAgentService,
@IExtensionsWorkbenchService private readonly extensionService: IExtensionsWorkbenchService,
) {
const agent = this.chatAgentService.getAgent(id)!;

const hoverElement = h(
'.chat-agent-hover@root',
[
h('.chat-agent-hover-header', [
h('.chat-agent-hover-icon@icon'),
h('.chat-agent-hover-details', [
h('.chat-agent-hover-name@name'),
h('.chat-agent-hover-extension', [
h('.chat-agent-hover-extension-name@extensionName'),
h('.chat-agent-hover-separator@separator'),
h('.chat-agent-hover-publisher@publisher'),
]),
]),
]),
h('.chat-agent-hover-description@description'),
]);
this.domNode = hoverElement.root;

if (agent.metadata.icon instanceof URI) {
const avatarIcon = dom.$<HTMLImageElement>('img.icon');
avatarIcon.src = FileAccess.uriToBrowserUri(agent.metadata.icon).toString(true);
hoverElement.icon.replaceChildren(dom.$('.avatar', undefined, avatarIcon));
} else if (agent.metadata.themeIcon) {
const avatarIcon = dom.$(ThemeIcon.asCSSSelector(agent.metadata.themeIcon));
hoverElement.icon.replaceChildren(dom.$('.avatar.codicon-avatar', undefined, avatarIcon));
}

hoverElement.name.textContent = `@${agent.name}`;
hoverElement.extensionName.textContent = agent.extensionDisplayName;
hoverElement.separator.textContent = '|';

const verifiedBadge = dom.$('span.extension-verified-publisher', undefined, renderIcon(verifiedPublisherIcon));
verifiedBadge.style.display = 'none';
dom.append(
hoverElement.publisher,
verifiedBadge,
agent.extensionPublisher);


const description = agent.description && !agent.description.endsWith('.') ?
`${agent.description}. ` :
(agent.description || '');
hoverElement.description.textContent = description;

// const marketplaceLink = document.createElement('a');
// marketplaceLink.setAttribute('href', `command:${showExtensionsWithIdsCommandId}?${encodeURIComponent(JSON.stringify([agent.extensionId.value]))}`);
// marketplaceLink.textContent = localize('marketplaceLabel', "View in Marketplace") + '.';
// hoverElement.description.appendChild(marketplaceLink);

this.extensionService.getExtensions([{ id: agent.extensionId.value }], CancellationToken.None).then(extensions => {
const extension = extensions[0];
if (extension?.publisherDomain?.verified) {
verifiedBadge.style.display = '';
}
});
}
}
35 changes: 23 additions & 12 deletions src/vs/workbench/contrib/chat/browser/chatListRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as dom from 'vs/base/browser/dom';
import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems';
import { alert } from 'vs/base/browser/ui/aria/aria';
import { Button } from 'vs/base/browser/ui/button/button';
import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory';
import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels';
import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
import { ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree';
Expand All @@ -16,6 +17,7 @@ import { IAsyncDataSource, ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/t
import { IAction } from 'vs/base/common/actions';
import { distinct } from 'vs/base/common/arrays';
import { disposableTimeout } from 'vs/base/common/async';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { Codicon } from 'vs/base/common/codicons';
import { Emitter, Event } from 'vs/base/common/event';
import { FuzzyScore } from 'vs/base/common/filters';
Expand All @@ -24,13 +26,18 @@ import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } fr
import { ResourceMap } from 'vs/base/common/map';
import { FileAccess, Schemas, matchesSomeScheme } from 'vs/base/common/network';
import { clamp } from 'vs/base/common/numbers';
import { IObservable, autorun, constObservable } from 'vs/base/common/observable';
import { basename } from 'vs/base/common/path';
import { basenameOrAuthority } from 'vs/base/common/resources';
import { equalsIgnoreCase } from 'vs/base/common/strings';
import { ThemeIcon } from 'vs/base/common/themables';
import { isUndefined } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { IMarkdownRenderResult, MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer';
import { Range } from 'vs/editor/common/core/range';
import { TextEdit } from 'vs/editor/common/languages';
import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel';
import { IModelService } from 'vs/editor/common/services/model';
import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService';
import { localize } from 'vs/nls';
import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem, createActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem';
Expand All @@ -40,6 +47,7 @@ import { ICommandService } from 'vs/platform/commands/common/commands';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { FileKind, FileType } from 'vs/platform/files/common/files';
import { IHoverService } from 'vs/platform/hover/browser/hover';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { WorkbenchCompressibleAsyncDataTree, WorkbenchList } from 'vs/platform/list/browser/listService';
Expand All @@ -50,6 +58,7 @@ import { ColorScheme } from 'vs/platform/theme/common/theme';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels';
import { ChatTreeItem, GeneratingPhrase, IChatCodeBlockInfo, IChatFileTreeInfo } from 'vs/workbench/contrib/chat/browser/chat';
import { ChatAgentHover } from 'vs/workbench/contrib/chat/browser/chatAgentHover';
import { ChatFollowups } from 'vs/workbench/contrib/chat/browser/chatFollowups';
import { ChatMarkdownDecorationsRenderer } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer';
import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions';
Expand All @@ -66,17 +75,12 @@ import { createFileIconThemableTreeContainerScope } from 'vs/workbench/contrib/f
import { IFilesConfiguration } from 'vs/workbench/contrib/files/common/files';
import { IMarkdownVulnerability, annotateSpecialMarkdownContent } from '../common/annotations';
import { CodeBlockModelCollection } from '../common/codeBlockModelCollection';
import { IModelService } from 'vs/editor/common/services/model';
import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel';
import { TextEdit } from 'vs/editor/common/languages';
import { IChatListItemRendererOptions } from './chat';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { autorun, constObservable, IObservable } from 'vs/base/common/observable';
import { isUndefined } from 'vs/base/common/types';

const $ = dom.$;

interface IChatListItemTemplate {
currentElement?: ChatTreeItem;
readonly rowContainer: HTMLElement;
readonly titleToolbar?: MenuWorkbenchToolBar;
readonly avatarContainer: HTMLElement;
Expand Down Expand Up @@ -148,6 +152,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
@ICommandService private readonly commandService: ICommandService,
@ITextModelService private readonly textModelService: ITextModelService,
@IModelService private readonly modelService: IModelService,
@IHoverService private readonly hoverService: IHoverService,
@IChatAgentNameService private readonly chatAgentNameService: IChatAgentNameService,
) {
super();
Expand Down Expand Up @@ -286,6 +291,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}
}));
}

templateDisposables.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), header, () => {
if (isResponseVM(template.currentElement) && template.currentElement.agent) {
const hover = this.instantiationService.createInstance(ChatAgentHover, template.currentElement.agent.id);
return hover.domNode;
}

return undefined;
}));

const template: IChatListItemTemplate = { avatarContainer, agentAvatarContainer, username, detail, referencesListContainer, value, rowContainer, elementDisposables, titleToolbar, templateDisposables, contextKeyService };
return template;
}
Expand All @@ -295,6 +310,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}

renderChatTreeItem(element: ChatTreeItem, index: number, templateData: IChatListItemTemplate): void {
templateData.currentElement = element;
const kind = isRequestVM(element) ? 'request' :
isResponseVM(element) ? 'response' :
'welcome';
Expand Down Expand Up @@ -416,11 +432,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}

templateData.detail.textContent = progressMsg;
if (element.agent) {
templateData.detail.title = progressMsg + (element.slashCommand?.description ? `\n${element.slashCommand.description}` : '');
} else {
templateData.detail.title = '';
}
}

private renderAvatar(element: ChatTreeItem, templateData: IChatListItemTemplate): void {
Expand Down Expand Up @@ -1038,7 +1049,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
disposables.add(toDisposable(() => this.codeBlocksByResponseId.delete(element.id)));
}

this.markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(result.element);
disposables.add(this.markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(result.element));

orderedDisposablesList.reverse().forEach(d => disposables.add(d));
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,58 +5,73 @@

import * as dom from 'vs/base/browser/dom';
import { toErrorMessage } from 'vs/base/common/errorMessage';
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { revive } from 'vs/base/common/marshalling';
import { URI } from 'vs/base/common/uri';
import { Location } from 'vs/editor/common/languages';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { ILabelService } from 'vs/platform/label/common/label';
import { ILogService } from 'vs/platform/log/common/log';
import { ChatAgentHover } from 'vs/workbench/contrib/chat/browser/chatAgentHover';
import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestTextPart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { contentRefUrl } from '../common/annotations';
import { IHoverService } from 'vs/platform/hover/browser/hover';
import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory';

const variableRefUrl = 'http://_vscodedecoration_';
const agentRefUrl = 'http://_chatagent_';

export class ChatMarkdownDecorationsRenderer {
constructor(
@IKeybindingService private readonly keybindingService: IKeybindingService,
@ILabelService private readonly labelService: ILabelService,
@ILogService private readonly logService: ILogService,
@IChatAgentService private readonly chatAgentService: IChatAgentService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IHoverService private readonly hoverService: IHoverService,
) { }

convertParsedRequestToMarkdown(parsedRequest: IParsedChatRequest): string {
let result = '';
for (const part of parsedRequest.parts) {
if (part instanceof ChatRequestTextPart) {
result += part.text;
} else if (part instanceof ChatRequestAgentPart) {
let text = part.text;
const isDupe = this.chatAgentService.getAgentsByName(part.agent.name).length > 1;
if (isDupe) {
text += ` (${part.agent.extensionPublisher})`;
}

result += `[${text}](${agentRefUrl}?${encodeURIComponent(part.agent.id)})`;
} else {
const uri = part instanceof ChatRequestDynamicVariablePart && part.data.map(d => d.value).find((d): d is URI => d instanceof URI)
|| undefined;
const title = uri ? encodeURIComponent(this.labelService.getUriLabel(uri, { relative: true })) :
part instanceof ChatRequestAgentPart ? part.agent.id :
'';

let text = part.text;
if (part instanceof ChatRequestAgentPart) {
const isDupe = this.chatAgentService.getAgentsByName(part.agent.name).length > 1;
if (isDupe) {
text += ` (${part.agent.extensionPublisher})`;
}
}

const text = part.text;
result += `[${text}](${variableRefUrl}?${title})`;
}
}

return result;
}

walkTreeAndAnnotateReferenceLinks(element: HTMLElement): void {
walkTreeAndAnnotateReferenceLinks(element: HTMLElement): IDisposable {
const store = new DisposableStore();
element.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('data-href');
if (href) {
if (href.startsWith(variableRefUrl)) {
if (href.startsWith(agentRefUrl)) {
const title = decodeURIComponent(href.slice(agentRefUrl.length + 1));
a.parentElement!.replaceChild(
this.renderAgentWidget(a.textContent!, title, store),
a);
} else if (href.startsWith(variableRefUrl)) {
const title = decodeURIComponent(href.slice(variableRefUrl.length + 1));
a.parentElement!.replaceChild(
this.renderResourceWidget(a.textContent!, title),
Expand All @@ -68,6 +83,18 @@ export class ChatMarkdownDecorationsRenderer {
}
}
});

return store;
}

private renderAgentWidget(name: string, id: string, store: DisposableStore): HTMLElement {
const container = dom.$('span.chat-resource-widget', undefined, dom.$('span', undefined, name));

store.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('element'), container, () => {
const hover = this.instantiationService.createInstance(ChatAgentHover, id);
return hover.domNode;
}));
return container;
}

private renderFileWidget(href: string, a: HTMLAnchorElement): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution {
{
extensionId: extension.description.identifier,
extensionPublisher: extension.description.publisherDisplayName ?? extension.description.publisher, // May not be present in OSS
extensionDisplayName: extension.description.displayName ?? extension.description.name,
id: providerDescriptor.id,
description: providerDescriptor.description,
metadata: {
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/chat/browser/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isEqual } from 'vs/base/common/resources';
import { isDefined } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import 'vs/css!./media/chat';
import 'vs/css!./media/chatHover';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
import { MenuId } from 'vs/platform/actions/common/actions';
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/chat/browser/media/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
width: 24px;
height: 24px;
border-radius: 50%;
outline: 1px solid var(--vscode-chat-requestBorder)
outline: 1px solid var(--vscode-chat-requestBorder);
}

.interactive-item-container .header .avatar.codicon-avatar {
Expand Down
Loading

0 comments on commit 6294089

Please sign in to comment.