mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
chat tip toolbar and accessibility improvements (#295235)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user