diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index a81fe449d62..e9356c85f9d 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -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'); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 4a5eb05db65..45a0d7a5724 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -37,6 +37,16 @@ export interface IChatTipService { */ readonly onDidDismissTip: Event; + /** + * Fired when the user navigates to a different tip (previous/next). + */ + readonly onDidNavigateTip: Event; + + /** + * Fired when the tip widget is hidden without dismissing the tip. + */ + readonly onDidHideTip: Event; + /** * 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; + + /** + * 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()); readonly onDidDismissTip = this._onDidDismissTip.event; + private readonly _onDidNavigateTip = this._register(new Emitter()); + readonly onDidNavigateTip = this._onDidNavigateTip.event; + + private readonly _onDidHideTip = this._register(new Emitter()); + readonly onDidHideTip = this._onDidHideTip.event; + private readonly _onDidDisableTips = this._register(new Emitter()); 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 { 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()); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 7a5ba17dcaa..7ccef8a28be 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -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()); private readonly _inChatTipContextKey: IContextKey; @@ -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 { + 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 { + 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 { + 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 { + accessor.get(IChatTipService).hideTip(); + } +}); + +//#endregion + //#region Tip context menu actions registerAction2(class DismissTipAction extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index 3077e67d2b5..67ecd94b482 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -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 {