add tip for welcome view, first request per session, improve styling (#294653)

This commit is contained in:
Megan Rogge
2026-02-11 14:35:14 -06:00
committed by GitHub
parent 71e1f05e3e
commit a5f53a88d0
6 changed files with 325 additions and 29 deletions

View File

@@ -43,14 +43,28 @@ export interface IChatTipService {
/**
* 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 VS Code session (resets on reload).
* Tips are only shown for requests created after the service was instantiated.
* 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;
/**
* Resets tip state for a new conversation.
* Call this when the chat widget binds to a new model.
*/
resetSession(): void;
/**
* Dismisses the current tip and allows a new one to be picked for the same request.
* The dismissed tip will not be shown again in this workspace.
@@ -133,7 +147,7 @@ const TIP_CATALOG: ITipDefinition[] = [
},
{
id: 'tip.undoChanges',
message: localize('tip.undoChanges', "Tip: You can undo Copilot's changes to any point by clicking Restore Checkpoint."),
message: localize('tip.undoChanges', "Tip: You can undo chat's changes to any point by clicking Restore Checkpoint."),
when: ContextKeyExpr.or(
ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent),
ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Edit),
@@ -142,7 +156,7 @@ const TIP_CATALOG: ITipDefinition[] = [
},
{
id: 'tip.customInstructions',
message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) so Copilot always has the context it needs when starting a task."),
message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) so chat always has the context it needs when starting a task."),
enabledCommands: ['workbench.action.chat.generateInstructions'],
excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true },
},
@@ -468,16 +482,17 @@ export class ChatTipService extends Disposable implements IChatTipService {
readonly onDidDisableTips = this._onDidDisableTips.event;
/**
* Timestamp when this service was instantiated.
* Timestamp when the current session started.
* Used to only show tips for requests created after this time.
* Resets on each {@link resetSession} call.
*/
private readonly _createdAt = Date.now();
private _sessionStartedAt = Date.now();
/**
* Whether a tip has already been shown in this window session.
* Only one tip is shown per session.
* Whether a chatResponse tip has already been shown in this conversation
* session. Only one response tip is shown per session.
*/
private _hasShownTip = false;
private _hasShownRequestTip = false;
/**
* The request ID that was assigned a tip (for stable rerenders).
@@ -490,6 +505,7 @@ export class ChatTipService extends Disposable implements IChatTipService {
private _shownTip: ITipDefinition | undefined;
private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed';
private static readonly _LAST_TIP_ID_KEY = 'chat.tip.lastTipId';
private readonly _tracker: TipEligibilityTracker;
constructor(
@@ -503,13 +519,20 @@ export class ChatTipService extends Disposable implements IChatTipService {
this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG));
}
resetSession(): void {
this._hasShownRequestTip = false;
this._shownTip = undefined;
this._tipRequestId = undefined;
this._sessionStartedAt = Date.now();
}
dismissTip(): void {
if (this._shownTip) {
const dismissed = this._getDismissedTipIds();
dismissed.push(this._shownTip.id);
this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.WORKSPACE, StorageTarget.MACHINE);
}
this._hasShownTip = false;
this._hasShownRequestTip = false;
this._shownTip = undefined;
this._tipRequestId = undefined;
this._onDidDismissTip.fire();
@@ -523,14 +546,25 @@ export class ChatTipService extends Disposable implements IChatTipService {
try {
const parsed = JSON.parse(raw);
this._logService.debug('#ChatTips dismissed:', parsed);
return Array.isArray(parsed) ? parsed : [];
if (!Array.isArray(parsed)) {
return [];
}
// Safety valve: if every known tip has been dismissed (for example, due to a
// past bug that dismissed the current tip on every new session), treat this
// as "no tips dismissed" so the feature can recover.
if (parsed.length >= TIP_CATALOG.length) {
return [];
}
return parsed;
} catch {
return [];
}
}
async disableTips(): Promise<void> {
this._hasShownTip = false;
this._hasShownRequestTip = false;
this._shownTip = undefined;
this._tipRequestId = undefined;
await this._configurationService.updateValue('chat.tips.enabled', false);
@@ -554,33 +588,89 @@ export class ChatTipService extends Disposable implements IChatTipService {
}
// Only show one tip per session
if (this._hasShownTip) {
if (this._hasShownRequestTip) {
return undefined;
}
// Only show tips for requests created after the service was instantiated
// This prevents showing tips for old requests being re-rendered after reload
if (requestTimestamp < this._createdAt) {
// 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;
}
// Find eligible tips (excluding dismissed ones)
const dismissedIds = new Set(this._getDismissedTipIds());
const eligibleTips = TIP_CATALOG.filter(tip => !dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService));
// Record the current mode for future eligibility decisions
return this._pickTip(requestId, contextKeyService);
}
getWelcomeTip(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;
}
// Return the already-shown tip for stable rerenders
if (this._tipRequestId === 'welcome' && this._shownTip) {
return this._createTip(this._shownTip);
}
const tip = this._pickTip('welcome', contextKeyService);
return tip;
}
private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined {
// Record the current mode for future eligibility decisions.
this._tracker.recordCurrentMode(contextKeyService);
if (eligibleTips.length === 0) {
return undefined;
const dismissedIds = new Set(this._getDismissedTipIds());
let selectedTip: ITipDefinition | undefined;
// Determine where to start in the catalog based on the last-shown tip.
const lastTipId = this._storageService.get(ChatTipService._LAST_TIP_ID_KEY, StorageScope.WORKSPACE);
const lastCatalogIndex = lastTipId ? TIP_CATALOG.findIndex(tip => tip.id === lastTipId) : -1;
const startIndex = lastCatalogIndex === -1 ? 0 : (lastCatalogIndex + 1) % TIP_CATALOG.length;
// Pass 1: walk TIP_CATALOG in a ring, picking the first tip that is both
// not dismissed and eligible for the current context.
for (let i = 0; i < TIP_CATALOG.length; i++) {
const idx = (startIndex + i) % TIP_CATALOG.length;
const candidate = TIP_CATALOG[idx];
if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) {
selectedTip = candidate;
break;
}
}
// Pick a random tip from eligible tips
const randomIndex = Math.floor(Math.random() * eligibleTips.length);
const selectedTip = eligibleTips[randomIndex];
// Pass 2: if everything was ineligible (e.g., user has already done all
// the suggested actions), still advance through the catalog but only skip
// tips that were explicitly dismissed.
if (!selectedTip) {
for (let i = 0; i < TIP_CATALOG.length; i++) {
const idx = (startIndex + i) % TIP_CATALOG.length;
const candidate = TIP_CATALOG[idx];
if (!dismissedIds.has(candidate.id)) {
selectedTip = candidate;
break;
}
}
}
// Final fallback: if even that fails (all tips dismissed), stick with the
// catalog order so rotation still progresses.
if (!selectedTip) {
selectedTip = TIP_CATALOG[startIndex];
}
// Persist the selected tip id so the next use advances to the following one.
this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);
// Record that we've shown a tip this session
this._hasShownTip = true;
this._tipRequestId = requestId;
this._hasShownRequestTip = sourceId !== 'welcome';
this._tipRequestId = sourceId;
this._shownTip = selectedTip;
return this._createTip(selectedTip);

View File

@@ -8,6 +8,10 @@
align-items: center;
gap: 4px;
margin-bottom: 8px;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--vscode-focusBorder);
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);
@@ -15,13 +19,56 @@
.interactive-item-container .chat-tip-widget .codicon-lightbulb {
font-size: 12px;
color: var(--vscode-descriptionForeground);
color: var(--vscode-notificationsWarningIcon-foreground);
}
.interactive-item-container .chat-tip-widget .rendered-markdown p {
margin: 0;
}
.chat-getting-started-tip-container {
position: absolute;
left: 12px;
right: 12px;
/* Initial vertical position; updated dynamically to align with input top */
bottom: 96px;
display: flex;
justify-content: center;
/* No horizontal padding so width exactly matches input */
padding: 0;
pointer-events: none;
z-index: 2;
}
.chat-getting-started-tip-container .chat-tip-widget {
pointer-events: auto;
display: flex;
align-items: center;
gap: 4px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
/* Match input inner padding (6px per side) so content edges align */
padding: 6px;
/* Darker background for welcome-state tip, distinct from input */
background-color: var(--vscode-editorWidget-background);
border-radius: 4px 4px 0 0;
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent));
border-bottom: none; /* Seamless attachment to input below */
box-shadow: none;
font-size: var(--vscode-chat-font-size-body-s);
font-family: var(--vscode-chat-font-family, inherit);
color: var(--vscode-descriptionForeground);
}
.chat-getting-started-tip-container .chat-tip-widget .codicon-lightbulb {
display: none;
}
.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 {

View File

@@ -78,6 +78,9 @@ import { IChatListItemTemplate } from './chatListRenderer.js';
import { ChatListWidget } from './chatListWidget.js';
import { ChatEditorOptions } from './chatOptions.js';
import { ChatViewWelcomePart, IChatSuggestedPrompts, IChatViewWelcomeContent } from '../viewsWelcome/chatViewWelcomeController.js';
import { IChatTipService } from '../chatTipService.js';
import { ChatTipContentPart } from './chatContentParts/chatTipContentPart.js';
import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js';
import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js';
const $ = dom.$;
@@ -233,6 +236,9 @@ export class ChatWidget extends Disposable implements IChatWidget {
private welcomeMessageContainer!: HTMLElement;
private readonly welcomePart: MutableDisposable<ChatViewWelcomePart> = this._register(new MutableDisposable());
private gettingStartedTipContainer: HTMLElement | undefined;
private readonly _gettingStartedTipPart = this._register(new MutableDisposable<DisposableStore>());
private readonly chatSuggestNextWidget: ChatSuggestNextWidget;
private bodyDimension: dom.Dimension | undefined;
@@ -368,6 +374,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,
@ILifecycleService private readonly lifecycleService: ILifecycleService,
@IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService,
@IChatTipService private readonly chatTipService: IChatTipService,
) {
super();
@@ -622,6 +629,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
} else {
this.listContainer = dom.append(this.container, $(`.interactive-list`));
dom.append(this.container, this.chatSuggestNextWidget.domNode);
this.gettingStartedTipContainer = dom.append(this.container, $('.chat-getting-started-tip-container', { style: 'display: none' }));
this.createInput(this.container, { renderFollowups, renderStyle, renderInputToolbarBelowInput });
}
@@ -840,9 +848,33 @@ export class ChatWidget extends Disposable implements IChatWidget {
*/
private updateChatViewVisibility(): void {
if (this.viewModel) {
const isStandardLayout = this.viewOptions.renderStyle !== 'compact' && this.viewOptions.renderStyle !== 'minimal';
const numItems = this.viewModel.getItems().length;
dom.setVisibility(numItems === 0, this.welcomeMessageContainer);
dom.setVisibility(numItems !== 0, this.listContainer);
// Show/hide the getting-started tip container based on empty state.
// Only use this in the standard chat layout where the welcome view is shown.
if (isStandardLayout && this.gettingStartedTipContainer) {
if (numItems === 0) {
this.renderGettingStartedTipIfNeeded();
this.container.classList.toggle('chat-has-getting-started-tip', !!this._gettingStartedTipPart.value);
} else {
// Dispose the cached tip part so the next empty state picks a
// fresh (rotated) tip instead of re-showing the stale one.
this._gettingStartedTipPart.clear();
dom.clearNode(this.gettingStartedTipContainer);
// Reset inline positioning from layoutGettingStartedTipPosition
// so the next render starts from the CSS defaults.
this.gettingStartedTipContainer.style.top = '';
this.gettingStartedTipContainer.style.bottom = '';
this.gettingStartedTipContainer.style.left = '';
this.gettingStartedTipContainer.style.right = '';
this.gettingStartedTipContainer.style.width = '';
dom.setVisibility(false, this.gettingStartedTipContainer);
this.container.classList.toggle('chat-has-getting-started-tip', false);
}
}
}
// Only show welcome getting started until extension is installed
@@ -904,6 +936,84 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
}
private renderGettingStartedTipIfNeeded(): void {
const tipContainer = this.gettingStartedTipContainer;
if (!tipContainer) {
return;
}
// Already showing a tip
if (this._gettingStartedTipPart.value) {
dom.setVisibility(true, tipContainer);
return;
}
const tip = this.chatTipService.getWelcomeTip(this.contextKeyService);
if (!tip) {
dom.setVisibility(false, tipContainer);
return;
}
const store = new DisposableStore();
const renderer = this.instantiationService.createInstance(ChatContentMarkdownRenderer);
const tipPart = store.add(this.instantiationService.createInstance(ChatTipContentPart,
tip,
renderer,
() => this.chatTipService.getWelcomeTip(this.contextKeyService),
));
tipContainer.appendChild(tipPart.domNode);
store.add(tipPart.onDidHide(() => {
tipPart.domNode.remove();
this._gettingStartedTipPart.clear();
dom.setVisibility(false, tipContainer);
this.container.classList.toggle('chat-has-getting-started-tip', false);
}));
this._gettingStartedTipPart.value = store;
dom.setVisibility(true, tipContainer);
// Best-effort synchronous position (works when layout is already settled,
// e.g. the very first render after page load).
this.layoutGettingStartedTipPosition();
// Also schedule a deferred correction for cases where the browser
// hasn't finished layout yet (e.g. returning to the welcome view
// after a conversation).
store.add(dom.scheduleAtNextAnimationFrame(dom.getWindow(tipContainer), () => {
this.layoutGettingStartedTipPosition();
}));
}
private layoutGettingStartedTipPosition(): void {
if (!this.container || !this.gettingStartedTipContainer || !this.inputPart) {
return;
}
const inputContainer = this.inputPart.inputContainerElement;
if (!inputContainer) {
return;
}
const containerRect = this.container.getBoundingClientRect();
const inputRect = inputContainer.getBoundingClientRect();
const tipRect = this.gettingStartedTipContainer.getBoundingClientRect();
// Align the tip horizontally with the input container.
const left = inputRect.left - containerRect.left;
this.gettingStartedTipContainer.style.left = `${left}px`;
this.gettingStartedTipContainer.style.right = 'auto';
this.gettingStartedTipContainer.style.width = `${inputRect.width}px`;
// Position the tip so its bottom edge sits flush against the input's
// top edge for a seamless visual connection.
const topOffset = inputRect.top - containerRect.top - tipRect.height;
if (topOffset > 0) {
this.gettingStartedTipContainer.style.top = `${topOffset}px`;
this.gettingStartedTipContainer.style.bottom = 'auto';
}
}
private _getGenerateInstructionsMessage(): IMarkdownString {
// Start checking for instruction files immediately if not already done
if (!this._instructionFilesCheckPromise) {
@@ -990,7 +1100,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
message: new MarkdownString(DISCLAIMER),
icon: Codicon.chatSparkle,
additionalMessage,
suggestedPrompts: this.getPromptFileSuggestions()
suggestedPrompts: this.getPromptFileSuggestions(),
};
}
@@ -1743,6 +1853,9 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.layout(this.bodyDimension.height, this.bodyDimension.width);
}
// Keep getting-started tip aligned with the top of the input
this.layoutGettingStartedTipPosition();
this._onDidChangeContentHeight.fire();
}));
}
@@ -1866,6 +1979,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
this.inputPart.clearTodoListWidget(model.sessionResource, false);
this.chatSuggestNextWidget.hide();
this.chatTipService.resetSession();
this._codeBlockModelCollection.clear();

View File

@@ -289,12 +289,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
private chatInputTodoListWidgetContainer!: HTMLElement;
private chatQuestionCarouselContainer!: HTMLElement;
private chatInputWidgetsContainer!: HTMLElement;
private inputContainer!: HTMLElement;
private readonly _widgetController = this._register(new MutableDisposable<ChatInputPartWidgetController>());
private contextUsageWidget?: ChatContextUsageWidget;
private contextUsageWidgetContainer!: HTMLElement;
private readonly _contextUsageDisposables = this._register(new MutableDisposable<DisposableStore>());
get inputContainerElement(): HTMLElement | undefined {
return this.inputContainer;
}
readonly height = observableValue<number>(this, 0);
private _inputEditor!: CodeEditorWidget;
@@ -1794,6 +1799,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.followupsContainer = elements.followupsContainer;
const inputAndSideToolbar = elements.inputAndSideToolbar; // The chat input and toolbar to the right
const inputContainer = elements.inputContainer; // The chat editor, attachments, and toolbars
this.inputContainer = inputContainer;
const editorContainer = elements.editorContainer;
this.attachmentsContainer = elements.attachmentsContainer;
this.attachedContextContainer = elements.attachedContextContainer;

View File

@@ -805,6 +805,12 @@ have to be updated for changes to the rules above, or to support more deeply nes
position: relative;
}
/* When a getting-started tip is shown, visually attach it to the input */
.interactive-session.chat-has-getting-started-tip .chat-input-container {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* Context usage widget container - positioned at top right of chat input */
.interactive-session .chat-input-container .chat-context-usage-container {
position: absolute;

View File

@@ -479,6 +479,39 @@ 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', () => {
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');
// 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');
});
test('excludes tip when tracked tool has been invoked', () => {
const mockToolsService = createMockToolsService();
const tip: ITipDefinition = {