add tips toolbar (#295175)

This commit is contained in:
Megan Rogge
2026-02-13 10:00:50 -06:00
committed by GitHub
parent 6e326e9ee1
commit 2a69f02ded
4 changed files with 235 additions and 3 deletions

View File

@@ -274,6 +274,7 @@ export class MenuId {
static readonly ChatTitleBarMenu = new MenuId('ChatTitleBarMenu');
static readonly ChatAttachmentsContext = new MenuId('ChatAttachmentsContext');
static readonly ChatTipContext = new MenuId('ChatTipContext');
static readonly ChatTipToolbar = new MenuId('ChatTipToolbar');
static readonly ChatToolOutputResourceToolbar = new MenuId('ChatToolOutputResourceToolbar');
static readonly ChatTextEditorMenu = new MenuId('ChatTextEditorMenu');
static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext');

View File

@@ -37,6 +37,16 @@ export interface IChatTipService {
*/
readonly onDidDismissTip: Event<void>;
/**
* Fired when the user navigates to a different tip (previous/next).
*/
readonly onDidNavigateTip: Event<IChatTip>;
/**
* Fired when the tip widget is hidden without dismissing the tip.
*/
readonly onDidHideTip: Event<void>;
/**
* Fired when tips are disabled.
*/
@@ -72,10 +82,28 @@ export interface IChatTipService {
*/
dismissTip(): void;
/**
* Hides the tip widget without permanently dismissing the tip.
* The tip may be shown again in a future session.
*/
hideTip(): void;
/**
* Disables tips permanently by setting the `chat.tips.enabled` configuration to false.
*/
disableTips(): Promise<void>;
/**
* Navigates to the next tip in the catalog without permanently dismissing the current one.
* @param contextKeyService The context key service to evaluate tip eligibility.
*/
navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined;
/**
* Navigates to the previous tip in the catalog without permanently dismissing the current one.
* @param contextKeyService The context key service to evaluate tip eligibility.
*/
navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined;
}
export interface ITipDefinition {
@@ -472,6 +500,12 @@ export class ChatTipService extends Disposable implements IChatTipService {
private readonly _onDidDismissTip = this._register(new Emitter<void>());
readonly onDidDismissTip = this._onDidDismissTip.event;
private readonly _onDidNavigateTip = this._register(new Emitter<IChatTip>());
readonly onDidNavigateTip = this._onDidNavigateTip.event;
private readonly _onDidHideTip = this._register(new Emitter<void>());
readonly onDidHideTip = this._onDidHideTip.event;
private readonly _onDidDisableTips = this._register(new Emitter<void>());
readonly onDidDisableTips = this._onDidDisableTips.event;
@@ -557,6 +591,13 @@ export class ChatTipService extends Disposable implements IChatTipService {
}
}
hideTip(): void {
this._hasShownRequestTip = false;
this._shownTip = undefined;
this._tipRequestId = undefined;
this._onDidHideTip.fire();
}
async disableTips(): Promise<void> {
this._hasShownRequestTip = false;
this._shownTip = undefined;
@@ -688,6 +729,40 @@ export class ChatTipService extends Disposable implements IChatTipService {
return this._createTip(selectedTip);
}
navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined {
return this._navigateTip(1, contextKeyService);
}
navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined {
return this._navigateTip(-1, contextKeyService);
}
private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined {
if (!this._shownTip) {
return undefined;
}
const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id);
if (currentIndex === -1) {
return undefined;
}
const dismissedIds = new Set(this._getDismissedTipIds());
for (let i = 1; i < TIP_CATALOG.length; i++) {
const idx = ((currentIndex + direction * i) % TIP_CATALOG.length + TIP_CATALOG.length) % TIP_CATALOG.length;
const candidate = TIP_CATALOG[idx];
if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) {
this._shownTip = candidate;
this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.PROFILE, StorageTarget.USER);
const tip = this._createTip(candidate);
this._onDidNavigateTip.fire(tip);
return tip;
}
}
return undefined;
}
private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean {
if (tip.when && !contextKeyService.contextMatchesRules(tip.when)) {
this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize());

View File

@@ -13,10 +13,11 @@ import { Emitter } from '../../../../../../base/common/event.js';
import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { localize, localize2 } from '../../../../../../nls.js';
import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js';
import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js';
import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js';
import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js';
import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js';
import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js';
import { IChatTip, IChatTipService } from '../../chatTipService.js';
@@ -30,6 +31,7 @@ export class ChatTipContentPart extends Disposable {
public readonly onDidHide = this._onDidHide.event;
private readonly _renderedContent = this._register(new MutableDisposable());
private readonly _toolbar = this._register(new MutableDisposable<MenuWorkbenchToolBar>());
private readonly _inChatTipContextKey: IContextKey<boolean>;
@@ -41,6 +43,7 @@ export class ChatTipContentPart extends Disposable {
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
@IMenuService private readonly _menuService: IMenuService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
super();
@@ -66,6 +69,14 @@ export class ChatTipContentPart extends Disposable {
}
}));
this._register(this._chatTipService.onDidNavigateTip(tip => {
this._renderTip(tip);
}));
this._register(this._chatTipService.onDidHideTip(() => {
this._onDidHide.fire();
}));
this._register(this._chatTipService.onDidDisableTips(() => {
this._onDidHide.fire();
}));
@@ -93,10 +104,22 @@ export class ChatTipContentPart extends Disposable {
private _renderTip(tip: IChatTip): void {
dom.clearNode(this.domNode);
this._toolbar.clear();
this.domNode.appendChild(renderIcon(Codicon.lightbulb));
const markdownContent = this._renderer.render(tip.content);
this._renderedContent.value = markdownContent;
this.domNode.appendChild(markdownContent.element);
// Toolbar with previous, next, and dismiss actions via MenuWorkbenchToolBar
const toolbarContainer = $('.chat-tip-toolbar');
this._toolbar.value = this._instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, MenuId.ChatTipToolbar, {
menuOptions: {
shouldForwardArgs: true,
},
});
this.domNode.appendChild(toolbarContainer);
const textContent = markdownContent.element.textContent ?? localize('chatTip', "Chat tip");
const hasLink = /\[.*?\]\(.*?\)/.test(tip.content.value);
const ariaLabel = hasLink
@@ -107,6 +130,94 @@ export class ChatTipContentPart extends Disposable {
}
}
//#region Tip toolbar actions
registerAction2(class PreviousTipAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.previousTip',
title: localize2('chatTip.previous', "Previous Tip"),
icon: Codicon.chevronLeft,
f1: false,
menu: [{
id: MenuId.ChatTipToolbar,
group: 'navigation',
order: 1,
}]
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const chatTipService = accessor.get(IChatTipService);
const contextKeyService = accessor.get(IContextKeyService);
chatTipService.navigateToPreviousTip(contextKeyService);
}
});
registerAction2(class NextTipAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.nextTip',
title: localize2('chatTip.next', "Next Tip"),
icon: Codicon.chevronRight,
f1: false,
menu: [{
id: MenuId.ChatTipToolbar,
group: 'navigation',
order: 2,
}]
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const chatTipService = accessor.get(IChatTipService);
const contextKeyService = accessor.get(IContextKeyService);
chatTipService.navigateToNextTip(contextKeyService);
}
});
registerAction2(class DismissTipToolbarAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.dismissTipToolbar',
title: localize2('chatTip.dismissButton', "Dismiss Tip"),
icon: Codicon.check,
f1: false,
menu: [{
id: MenuId.ChatTipToolbar,
group: 'navigation',
order: 3,
}]
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
accessor.get(IChatTipService).dismissTip();
}
});
registerAction2(class CloseTipToolbarAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.closeTip',
title: localize2('chatTip.close', "Close Tips"),
icon: Codicon.close,
f1: false,
menu: [{
id: MenuId.ChatTipToolbar,
group: 'navigation',
order: 4,
}]
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
accessor.get(IChatTipService).hideTip();
}
});
//#endregion
//#region Tip context menu actions
registerAction2(class DismissTipAction extends Action2 {

View File

@@ -16,7 +16,6 @@
font-family: var(--vscode-chat-font-family, inherit);
color: var(--vscode-descriptionForeground);
position: relative;
overflow: hidden;
}
.interactive-item-container .chat-tip-widget .codicon-lightbulb {
@@ -37,6 +36,52 @@
margin: 0;
}
.interactive-item-container .chat-tip-widget .chat-tip-toolbar,
.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar {
opacity: 0;
pointer-events: none;
}
.interactive-item-container .chat-tip-widget .chat-tip-toolbar > .monaco-toolbar,
.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar > .monaco-toolbar {
position: absolute;
top: -15px;
right: 10px;
height: 26px;
line-height: 26px;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-chat-requestBorder);
border-radius: var(--vscode-cornerRadius-medium);
z-index: 100;
transition: opacity 0.1s ease-in-out;
}
.interactive-item-container .chat-tip-widget:hover .chat-tip-toolbar,
.interactive-item-container .chat-tip-widget:focus-within .chat-tip-toolbar,
.chat-getting-started-tip-container .chat-tip-widget:hover .chat-tip-toolbar,
.chat-getting-started-tip-container .chat-tip-widget:focus-within .chat-tip-toolbar {
opacity: 1;
pointer-events: auto;
}
.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item,
.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item {
height: 24px;
width: 24px;
margin: 1px 2px;
}
.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label,
.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label {
color: var(--vscode-descriptionForeground);
}
.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover,
.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover {
background-color: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-foreground);
}
.chat-getting-started-tip-container {
margin-bottom: -4px; /* Counter the flex gap */
width: 100%;
@@ -57,7 +102,7 @@
font-size: var(--vscode-chat-font-size-body-s);
font-family: var(--vscode-chat-font-family, inherit);
color: var(--vscode-descriptionForeground);
overflow: hidden;
position: relative;
}
.chat-getting-started-tip-container .chat-tip-widget a {