chat tip toolbar and accessibility improvements (#295235)

This commit is contained in:
Megan Rogge
2026-02-13 15:06:37 -06:00
committed by GitHub
parent 8233bb9730
commit 16f401a1a9
7 changed files with 107 additions and 324 deletions

View File

@@ -52,20 +52,8 @@ export interface IChatTipService {
*/
readonly onDidDisableTips: Event<void>;
/**
* 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<void>());
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<void> {
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<boolean>('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<boolean>('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;

View File

@@ -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<void> {
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<void> {
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<boolean>({
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();
}
}
});

View File

@@ -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;
}
}

View File

@@ -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<Ch
private readonly _autoRepliedQuestionCarousels = new Set<string>();
private readonly _autoReply: ChatQuestionCarouselAutoReply;
private _activeTipPart: ChatTipContentPart | undefined;
private readonly _notifiedQuestionCarousels = new Set<string>();
private readonly _questionCarouselToast = this._register(new DisposableStore());
@@ -259,7 +255,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,
@IChatService private readonly chatService: IChatService,
@IChatTipService private readonly chatTipService: IChatTipService,
@IHostService private readonly hostService: IHostService,
@IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService,
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
@@ -308,17 +303,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
return Iterable.concat(this._editorPool.inUse(), this._toolEditorPool.inUse());
}
hasTipFocus(): boolean {
return this._activeTipPart?.hasFocus() ?? false;
}
focusTip(): boolean {
if (!this._activeTipPart) {
return false;
}
this._activeTipPart.focus();
return true;
}
private traceLayout(method: string, message: string) {
if (forceVerboseLayoutTracing) {
@@ -1076,32 +1061,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
dom.clearNode(templateData.value);
const parts: IChatContentPart[] = [];
// Render tip above the request message (if available)
const tip = this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService);
if (tip) {
const tipPart = this.instantiationService.createInstance(ChatTipContentPart,
tip,
this.chatContentMarkdownRenderer,
() => 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 = {

View File

@@ -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.

View File

@@ -255,6 +255,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
private readonly welcomePart: MutableDisposable<ChatViewWelcomePart> = this._register(new MutableDisposable());
private readonly _gettingStartedTipPart = this._register(new MutableDisposable<DisposableStore>());
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<boolean>('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;

View File

@@ -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', () => {