diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 08f1c161b3a..bcc4e30c205 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -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 { - 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('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); 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 cc9cfd47f39..d99c14f550b 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 @@ -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 { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 13c0fb84ae1..9ed052f6af2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -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 = this._register(new MutableDisposable()); + private gettingStartedTipContainer: HTMLElement | undefined; + private readonly _gettingStartedTipPart = this._register(new MutableDisposable()); + 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(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 518c735c5d3..5d93d052632 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -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()); private contextUsageWidget?: ChatContextUsageWidget; private contextUsageWidgetContainer!: HTMLElement; private readonly _contextUsageDisposables = this._register(new MutableDisposable()); + get inputContainerElement(): HTMLElement | undefined { + return this.inputContainer; + } + readonly height = observableValue(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; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 4de68fd4633..f3c634ff743 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -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; 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 3ee8fdae9f6..442293c1998 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -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 = {