From 16f401a1a94da192f1e774f97a40ea2a5c44797c Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 13 Feb 2026 15:06:37 -0600 Subject: [PATCH] chat tip toolbar and accessibility improvements (#295235) --- .../contrib/chat/browser/chatTipService.ts | 86 ++--------- .../chatContentParts/chatTipContentPart.ts | 71 ++++++---- .../chatContentParts/media/chatTipContent.css | 57 +------- .../chat/browser/widget/chatListRenderer.ts | 41 ------ .../chat/browser/widget/chatListWidget.ts | 13 -- .../contrib/chat/browser/widget/chatWidget.ts | 30 +++- .../chat/test/browser/chatTipService.test.ts | 133 +++--------------- 7 files changed, 107 insertions(+), 324 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 45a0d7a5724..1047e4df25c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -52,20 +52,8 @@ export interface IChatTipService { */ readonly onDidDisableTips: Event; - /** - * Gets a tip to show for a request, or undefined if a tip has already been shown this session. - * Only one tip is shown per conversation session (resets when switching conversations). - * Tips are suppressed if a welcome tip was already shown in this session. - * Tips are only shown for requests created after the current session started. - * @param requestId The unique ID of the request (used for stable rerenders). - * @param requestTimestamp The timestamp when the request was created. - * @param contextKeyService The context key service to evaluate tip eligibility. - */ - getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined; - /** * Gets a tip to show on the welcome/getting-started view. - * Unlike {@link getNextTip}, this does not require a request and skips request-timestamp checks. * Returns the same tip on repeated calls for stable rerenders. */ getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined; @@ -104,6 +92,11 @@ export interface IChatTipService { * @param contextKeyService The context key service to evaluate tip eligibility. */ navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Clears all dismissed tips so they can be shown again. + */ + clearDismissedTips(): void; } export interface ITipDefinition { @@ -509,19 +502,6 @@ export class ChatTipService extends Disposable implements IChatTipService { private readonly _onDidDisableTips = this._register(new Emitter()); readonly onDidDisableTips = this._onDidDisableTips.event; - /** - * Timestamp when the current session started. - * Used to only show tips for requests created after this time. - * Resets on each {@link resetSession} call. - */ - private _sessionStartedAt = Date.now(); - - /** - * Whether a chatResponse tip has already been shown in this conversation - * session. Only one response tip is shown per session. - */ - private _hasShownRequestTip = false; - /** * The request ID that was assigned a tip (for stable rerenders). */ @@ -548,10 +528,8 @@ export class ChatTipService extends Disposable implements IChatTipService { } resetSession(): void { - this._hasShownRequestTip = false; this._shownTip = undefined; this._tipRequestId = undefined; - this._sessionStartedAt = Date.now(); } dismissTip(): void { @@ -560,7 +538,13 @@ export class ChatTipService extends Disposable implements IChatTipService { dismissed.push(this._shownTip.id); this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.PROFILE, StorageTarget.MACHINE); } - this._hasShownRequestTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidDismissTip.fire(); + } + + clearDismissedTips(): void { + this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); this._shownTip = undefined; this._tipRequestId = undefined; this._onDidDismissTip.fire(); @@ -592,63 +576,18 @@ 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; this._tipRequestId = undefined; await this._configurationService.updateValue('chat.tips.enabled', false); this._onDidDisableTips.fire(); } - getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined { - // Check if tips are enabled - if (!this._configurationService.getValue('chat.tips.enabled')) { - return undefined; - } - - // Only show tips for Copilot - if (!this._isCopilotEnabled()) { - return undefined; - } - - // Only show tips in the main chat panel, not in terminal/editor inline chat - if (!this._isChatLocation(contextKeyService)) { - return undefined; - } - - // Check if this is the request that was assigned a tip (for stable rerenders) - if (this._tipRequestId === requestId && this._shownTip) { - return this._createTip(this._shownTip); - } - - // A new request arrived while we already showed a tip, hide the old one - if (this._hasShownRequestTip && this._tipRequestId && this._tipRequestId !== requestId) { - this._shownTip = undefined; - this._tipRequestId = undefined; - this._onDidDismissTip.fire(); - return undefined; - } - - // Only show one tip per session - if (this._hasShownRequestTip) { - return undefined; - } - - // Only show tips for requests created after the current session started. - // This prevents showing tips for old requests being re-rendered. - if (requestTimestamp < this._sessionStartedAt) { - return undefined; - } - - return this._pickTip(requestId, contextKeyService); - } - getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined { // Check if tips are enabled if (!this._configurationService.getValue('chat.tips.enabled')) { @@ -722,7 +661,6 @@ export class ChatTipService extends Disposable implements IChatTipService { this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.PROFILE, StorageTarget.USER); // Record that we've shown a tip this session - this._hasShownRequestTip = sourceId !== 'welcome'; this._tipRequestId = sourceId; this._shownTip = selectedTip; 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 7ccef8a28be..7a9859e7a58 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -6,17 +6,19 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; -import { status } from '../../../../../../base/browser/ui/aria/aria.js'; import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.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 { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.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'; @@ -64,6 +66,7 @@ export class ChatTipContentPart extends Disposable { const nextTip = this._getNextTip(); if (nextTip) { this._renderTip(nextTip); + this.focus(); } else { this._onDidHide.fire(); } @@ -71,6 +74,7 @@ export class ChatTipContentPart extends Disposable { this._register(this._chatTipService.onDidNavigateTip(tip => { this._renderTip(tip); + this.focus(); })); this._register(this._chatTipService.onDidHideTip(() => { @@ -123,10 +127,9 @@ export class ChatTipContentPart extends Disposable { const textContent = markdownContent.element.textContent ?? localize('chatTip', "Chat tip"); const hasLink = /\[.*?\]\(.*?\)/.test(tip.content.value); const ariaLabel = hasLink - ? localize('chatTipWithAction', "{0} Tab to the action.", textContent) + ? localize('chatTipWithAction', "{0} Tab to reach the action.", textContent) : textContent; this.domNode.setAttribute('aria-label', ariaLabel); - status(ariaLabel); } } @@ -136,7 +139,7 @@ registerAction2(class PreviousTipAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.previousTip', - title: localize2('chatTip.previous', "Previous Tip"), + title: localize2('chatTip.previous', "Previous tip"), icon: Codicon.chevronLeft, f1: false, menu: [{ @@ -158,7 +161,7 @@ registerAction2(class NextTipAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.nextTip', - title: localize2('chatTip.next', "Next Tip"), + title: localize2('chatTip.next', "Next tip"), icon: Codicon.chevronRight, f1: false, menu: [{ @@ -180,7 +183,7 @@ registerAction2(class DismissTipToolbarAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.dismissTipToolbar', - title: localize2('chatTip.dismissButton', "Dismiss Tip"), + title: localize2('chatTip.dismissButton', "Dismiss tip"), icon: Codicon.check, f1: false, menu: [{ @@ -196,26 +199,6 @@ registerAction2(class DismissTipToolbarAction extends Action2 { } }); -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 @@ -244,17 +227,51 @@ registerAction2(class DisableTipsAction extends Action2 { super({ id: 'workbench.action.chat.disableTips', title: localize2('chatTip.disableTips', "Disable tips"), + icon: Codicon.bellSlash, f1: false, menu: [{ id: MenuId.ChatTipContext, group: 'chatTip', order: 2, + }, { + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 5, }] }); } override async run(accessor: ServicesAccessor): Promise { - await accessor.get(IChatTipService).disableTips(); + const dialogService = accessor.get(IDialogService); + const chatTipService = accessor.get(IChatTipService); + const commandService = accessor.get(ICommandService); + + const { result } = await dialogService.prompt({ + message: localize('chatTip.disableConfirmTitle', "Disable tips?"), + custom: { + markdownDetails: [{ + markdown: new MarkdownString(localize('chatTip.disableConfirmDetail', "New tips are added frequently to help you get the most out of Copilot. You can re-enable tips anytime from the `chat.tips.enabled` setting.")), + }], + }, + buttons: [ + { + label: localize('chatTip.disableConfirmButton', "Disable tips"), + run: () => true, + }, + { + label: localize('chatTip.openSettingButton', "Open Setting"), + run: () => { + commandService.executeCommand('workbench.action.openSettings', 'chat.tips.enabled'); + return false; + }, + }, + ], + cancelButton: true, + }); + + if (result) { + await chatTipService.disableTips(); + } } }); 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 67ecd94b482..33cd5d57831 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 @@ -3,49 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.interactive-item-container .chat-tip-widget { - display: flex; - align-items: center; - gap: 4px; - margin-bottom: 8px; - padding: 6px 10px; - border-radius: 4px; - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); - background-color: var(--vscode-editorWidget-background); - font-size: var(--vscode-chat-font-size-body-s); - font-family: var(--vscode-chat-font-family, inherit); - color: var(--vscode-descriptionForeground); - position: relative; -} - -.interactive-item-container .chat-tip-widget .codicon-lightbulb { - font-size: 12px; - color: var(--vscode-notificationsWarningIcon-foreground); -} - -.interactive-item-container .chat-tip-widget a { - color: var(--vscode-textLink-foreground); -} - -.interactive-item-container .chat-tip-widget a:hover, -.interactive-item-container .chat-tip-widget a:active { - color: var(--vscode-textLink-activeForeground); -} - -.interactive-item-container .chat-tip-widget .rendered-markdown p { - 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; + top: -20px; right: 10px; height: 26px; line-height: 26px; @@ -56,27 +21,23 @@ 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); + padding: 4px; } -.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); @@ -122,17 +83,3 @@ .chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p { margin: 0; } - -/* Override bubble styling for tip widget's rendered markdown in chat editor */ -.interactive-session:not(.chat-widget > .interactive-session) { - .interactive-item-container.interactive-request .value .chat-tip-widget .rendered-markdown { - background-color: transparent; - border-radius: 0; - padding: 0; - max-width: none; - margin-left: 0; - width: auto; - margin-bottom: 0; - position: static; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index df048af0897..47d1d634c6c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -96,7 +96,6 @@ import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; import { ChatThinkingContentPart } from './chatContentParts/chatThinkingContentPart.js'; import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; -import { ChatTipContentPart } from './chatContentParts/chatTipContentPart.js'; import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js'; import { ChatWorkspaceEditContentPart } from './chatContentParts/chatWorkspaceEditContentPart.js'; import { ChatToolInvocationPart } from './chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; @@ -105,7 +104,6 @@ import { ChatEditorOptions } from './chatOptions.js'; import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/codeBlockPart.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; -import { IChatTipService } from '../chatTipService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; @@ -188,8 +186,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(); private readonly _autoReply: ChatQuestionCarouselAutoReply; - private _activeTipPart: ChatTipContentPart | undefined; - private readonly _notifiedQuestionCarousels = new Set(); private readonly _questionCarouselToast = this._register(new DisposableStore()); @@ -259,7 +255,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), - ); - templateData.value.appendChild(tipPart.domNode); - this._activeTipPart = tipPart; - templateData.elementDisposables.add(tipPart); - templateData.elementDisposables.add(tipPart.onDidHide(() => { - tipPart.domNode.remove(); - if (this._activeTipPart === tipPart) { - this._activeTipPart = undefined; - } - })); - templateData.elementDisposables.add({ - dispose: () => { - if (this._activeTipPart === tipPart) { - this._activeTipPart = undefined; - } - } - }); - } - let inlineSlashCommandRendered = false; content.forEach((data, contentIndex) => { const context: IChatContentPartRenderContext = { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 50b40d59419..45d54dedc15 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -789,20 +789,7 @@ export class ChatListWidget extends Disposable { return this._renderer.editorsInUse(); } - /** - * Whether the active tip currently has focus. - */ - hasTipFocus(): boolean { - return this._renderer.hasTipFocus(); - } - /** - * Focus the active tip, if any. - * @returns Whether a tip was focused. - */ - focusTip(): boolean { - return this._renderer.focusTip(); - } /** * Get template data for a request ID. diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 3f1d1497160..5c144cd73ae 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -255,6 +255,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly welcomePart: MutableDisposable = this._register(new MutableDisposable()); private readonly _gettingStartedTipPart = this._register(new MutableDisposable()); + private _gettingStartedTipPartRef: ChatTipContentPart | undefined; private readonly chatSuggestNextWidget: ChatSuggestNextWidget; @@ -419,6 +420,23 @@ export class ChatWidget extends Disposable implements IChatWidget { this._register(this.chatEntitlementService.onDidChangeAnonymous(() => this.renderWelcomeViewContentIfNeeded())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('chat.tips.enabled')) { + if (!this.configurationService.getValue('chat.tips.enabled')) { + // Clear the existing tip so it doesn't linger + if (this.inputPart) { + this._gettingStartedTipPartRef = undefined; + this._gettingStartedTipPart.clear(); + const tipContainer = this.inputPart.gettingStartedTipContainerElement; + dom.clearNode(tipContainer); + dom.setVisibility(false, tipContainer); + } + } else { + this.updateChatViewVisibility(); + } + } + })); + this._register(bindContextKey(decidedChatEditingResourceContextKey, contextKeyService, (reader) => { const currentSession = this._editingSession.read(reader); if (!currentSession) { @@ -768,12 +786,16 @@ export class ChatWidget extends Disposable implements IChatWidget { } toggleTipFocus(): boolean { - if (this.listWidget.hasTipFocus()) { + if (this._gettingStartedTipPartRef?.hasFocus()) { this.focusInput(); return true; } - return this.listWidget.focusTip(); + if (!this._gettingStartedTipPartRef) { + return false; + } + this._gettingStartedTipPartRef.focus(); + return true; } hasInputFocus(): boolean { @@ -879,6 +901,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } else { // Dispose the cached tip part so the next empty state picks a // fresh (rotated) tip instead of re-showing the stale one. + this._gettingStartedTipPartRef = undefined; this._gettingStartedTipPart.clear(); dom.clearNode(tipContainer); dom.setVisibility(false, tipContainer); @@ -972,11 +995,14 @@ export class ChatWidget extends Disposable implements IChatWidget { () => this.chatTipService.getWelcomeTip(this.contextKeyService), )); tipContainer.appendChild(tipPart.domNode); + this._gettingStartedTipPartRef = tipPart; store.add(tipPart.onDidHide(() => { tipPart.domNode.remove(); + this._gettingStartedTipPartRef = undefined; this._gettingStartedTipPart.clear(); dom.setVisibility(false, tipContainer); + this.focusInput(); })); this._gettingStartedTipPart.value = store; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index e923a890751..28f9b609fa1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -78,49 +78,22 @@ suite('ChatTipService', () => { instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); }); - test('returns a tip for new requests with timestamp after service creation', () => { + test('returns a welcome tip', () => { const service = createService(); - const now = Date.now(); - // Request created after service initialization - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip, 'Should return a tip for requests created after service instantiation'); + const tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip, 'Should return a welcome tip'); assert.ok(tip.id.startsWith('tip.'), 'Tip should have a valid ID'); assert.ok(tip.content.value.length > 0, 'Tip should have content'); }); - test('returns undefined for old requests with timestamp before service creation', () => { + test('returns same welcome tip on rerender', () => { const service = createService(); - const now = Date.now(); - // Request created before service initialization (simulating restored chat) - const tip = service.getNextTip('old-request', now - 10000, contextKeyService); - assert.strictEqual(tip, undefined, 'Should not return a tip for requests created before service instantiation'); - }); - - test('only shows one tip per session', () => { - const service = createService(); - const now = Date.now(); - - // First request gets a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip1, 'First request should get a tip'); - - // Second request does not get a tip - const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); - assert.strictEqual(tip2, undefined, 'Second request should not get a tip'); - }); - - test('returns same tip on rerender of same request', () => { - const service = createService(); - const now = Date.now(); - - // First call gets a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip1 = service.getWelcomeTip(contextKeyService); assert.ok(tip1); - // Same request ID gets the same tip on rerender - const tip2 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip2 = service.getWelcomeTip(contextKeyService); assert.ok(tip2); assert.strictEqual(tip1.id, tip2.id, 'Should return same tip for stable rerender'); assert.strictEqual(tip1.content.value, tip2.content.value); @@ -128,93 +101,56 @@ suite('ChatTipService', () => { test('returns undefined when Copilot is not enabled', () => { const service = createService(/* hasCopilot */ false); - const now = Date.now(); - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip = service.getWelcomeTip(contextKeyService); assert.strictEqual(tip, undefined, 'Should not return a tip when Copilot is not enabled'); }); test('returns undefined when tips setting is disabled', () => { const service = createService(/* hasCopilot */ true, /* tipsEnabled */ false); - const now = Date.now(); - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip = service.getWelcomeTip(contextKeyService); assert.strictEqual(tip, undefined, 'Should not return a tip when tips setting is disabled'); }); test('returns undefined when location is terminal', () => { const service = createService(); - const now = Date.now(); const terminalContextKeyService = new MockContextKeyServiceWithRulesMatching(); terminalContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.Terminal); - const tip = service.getNextTip('request-1', now + 1000, terminalContextKeyService); + const tip = service.getWelcomeTip(terminalContextKeyService); assert.strictEqual(tip, undefined, 'Should not return a tip in terminal inline chat'); }); test('returns undefined when location is editor inline', () => { const service = createService(); - const now = Date.now(); const editorContextKeyService = new MockContextKeyServiceWithRulesMatching(); editorContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.EditorInline); - const tip = service.getNextTip('request-1', now + 1000, editorContextKeyService); + const tip = service.getWelcomeTip(editorContextKeyService); assert.strictEqual(tip, undefined, 'Should not return a tip in editor inline chat'); }); - test('old requests do not consume the session tip allowance', () => { - const service = createService(); - const now = Date.now(); - - // Old request should not consume the tip allowance - const oldTip = service.getNextTip('old-request', now - 10000, contextKeyService); - assert.strictEqual(oldTip, undefined); - - // New request should still be able to get a tip - const newTip = service.getNextTip('new-request', now + 1000, contextKeyService); - assert.ok(newTip, 'New request should get a tip after old request was skipped'); - }); - - test('multiple old requests do not affect new request tip', () => { - const service = createService(); - const now = Date.now(); - - // Simulate multiple restored requests being rendered - service.getNextTip('old-1', now - 30000, contextKeyService); - service.getNextTip('old-2', now - 20000, contextKeyService); - service.getNextTip('old-3', now - 10000, contextKeyService); - - // New request should still get a tip - const tip = service.getNextTip('new-request', now + 1000, contextKeyService); - assert.ok(tip, 'New request should get a tip after multiple old requests'); - }); - test('dismissTip excludes the dismissed tip and allows a new one', () => { const service = createService(); - const now = Date.now(); - // Get a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip1 = service.getWelcomeTip(contextKeyService); assert.ok(tip1); - // Dismiss it service.dismissTip(); - // Next call should return a different tip (since the dismissed one is excluded) - const tip2 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip2 = service.getWelcomeTip(contextKeyService); if (tip2) { assert.notStrictEqual(tip1.id, tip2.id, 'Dismissed tip should not be shown again'); } - // tip2 may be undefined if it was the only eligible tip — that's also valid }); test('dismissTip fires onDidDismissTip event', () => { const service = createService(); - const now = Date.now(); - service.getNextTip('request-1', now + 1000, contextKeyService); + service.getWelcomeTip(contextKeyService); let fired = false; testDisposables.add(service.onDidDismissTip(() => { fired = true; })); @@ -225,9 +161,8 @@ suite('ChatTipService', () => { test('disableTips fires onDidDisableTips event', async () => { const service = createService(); - const now = Date.now(); - service.getNextTip('request-1', now + 1000, contextKeyService); + service.getWelcomeTip(contextKeyService); let fired = false; testDisposables.add(service.onDidDisableTips(() => { fired = true; })); @@ -238,20 +173,15 @@ suite('ChatTipService', () => { test('disableTips resets state so re-enabling works', async () => { const service = createService(); - const now = Date.now(); - // Show a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip1 = service.getWelcomeTip(contextKeyService); assert.ok(tip1); - // Disable tips await service.disableTips(); - // Re-enable tips configurationService.setUserConfiguration('chat.tips.enabled', true); - // Should be able to get a tip again on a new request - const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); + const tip2 = service.getWelcomeTip(contextKeyService); assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); @@ -501,37 +431,16 @@ suite('ChatTipService', () => { assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); }); - test('resetSession allows tips in a new conversation', () => { + test('resetSession allows a new welcome tip', () => { const service = createService(); - const now = Date.now(); - // Show a tip in the first conversation - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip1, 'First request should get a tip'); + const tip1 = service.getWelcomeTip(contextKeyService); + assert.ok(tip1, 'Should get a welcome tip'); - // Second request — no tip (one per session) - const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); - assert.strictEqual(tip2, undefined, 'Second request should not get a tip'); - - // Start a new conversation service.resetSession(); - // New request after reset should get a tip - const tip3 = service.getNextTip('request-3', Date.now() + 1000, contextKeyService); - assert.ok(tip3, 'First request after resetSession should get a tip'); - }); - - test('chatResponse tip shows regardless of welcome tip', () => { - const service = createService(); - const now = Date.now(); - - // Show a welcome tip (simulating the getting-started view) - const welcomeTip = service.getWelcomeTip(contextKeyService); - assert.ok(welcomeTip, 'Welcome tip should be shown'); - - // First new request should still get a chatResponse tip - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip, 'ChatResponse tip should show even when welcome tip was shown'); + const tip2 = service.getWelcomeTip(contextKeyService); + assert.ok(tip2, 'Should get a welcome tip after resetSession'); }); test('excludes tip when tracked tool has been invoked', () => {