From 9d9d7729481af249de83e5ade4a756e52c2bfe17 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Tue, 9 Jun 2026 14:53:01 -0700 Subject: [PATCH 01/24] Add chat quota trajectory nudge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 193 ++++++++++++- .../input/chatInputNotificationService.ts | 2 + .../input/chatInputNotificationWidget.ts | 7 +- .../media/chatInputNotificationWidget.css | 23 +- .../browser/chatQuotaNotification.test.ts | 262 +++++++++++++++++- 5 files changed, 467 insertions(+), 20 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 57d4bcd91dad41..c0aa7f2dce6881 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -3,11 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { safeIntl } from '../../../../base/common/date.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { ChatEntitlement, IChatEntitlementService, IQuotaSnapshot, IRateLimitSnapshot } from '../../../services/chat/common/chatEntitlementService.js'; import { isSelectedModelCopilot, SELECTED_MODEL_STORAGE_KEY_PREFIX } from '../common/chatSelectedModel.js'; @@ -16,6 +19,37 @@ import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotifi const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; const THRESHOLDS = [50, 75, 90, 95]; +const TRAJECTORY_DAILY_USAGE_THRESHOLD = 3.5; +const TRAJECTORY_MINIMUM_PERCENT_USED = 10; +const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; +const TRAJECTORY_TREATMENT = 'chatQuotaTrajectoryNudge'; +const TRAJECTORY_DISMISSED_STORAGE_KEY = 'chat.quotaTrajectory.dismissedPeriod'; +const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://www.microsoft.com'; + +const enum QuotaNotificationKind { + None, + Trajectory, +} + +type ChatQuotaTrajectoryNudgeAction = 'learnMore'; + +type ChatQuotaTrajectoryNudgeEvent = { + severity: 'info'; + entitlement: string; + averageDailyUsage: number; + percentUsed: number; + action?: ChatQuotaTrajectoryNudgeAction; +}; + +type ChatQuotaTrajectoryNudgeClassification = { + owner: 'rfeltis'; + comment: 'Tracks when the chat quota trajectory nudge is shown and when users interact with its call to action.'; + severity: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The severity of the quota trajectory nudge shown to the user.' }; + entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user entitlement when the quota trajectory nudge was shown.' }; + averageDailyUsage: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The average daily monthly quota usage percentage that caused the nudge.' }; + percentUsed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The monthly quota percentage used when the nudge was shown.' }; + action?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The quota trajectory nudge action the user selected.' }; +}; /** * Core-side workbench contribution that shows chat input notifications for @@ -45,6 +79,9 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _prevAdditionalUsageEnabled: boolean | undefined; private _prevSessionPercentUsed: number | undefined; private _prevWeeklyPercentUsed: number | undefined; + private _trajectoryNudgeEnabled = false; + private _activeQuotaNotificationKind = QuotaNotificationKind.None; + private _lastLoggedTrajectoryShownSignature: string | undefined; constructor( @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @@ -52,12 +89,24 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo @IContextKeyService private readonly _contextKeyService: IContextKeyService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IStorageService private readonly _storageService: IStorageService, + @IWorkbenchAssignmentService private readonly _assignmentService: IWorkbenchAssignmentService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._register(this._chatEntitlementService.onDidChangeQuotaRemaining(() => this._update())); this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => this._update())); this._register(this._chatEntitlementService.onDidChangeEntitlement(() => this._update())); + this._register(this._assignmentService.onDidRefetchAssignments(() => this._updateTrajectoryTreatment())); + this._register(this._chatInputNotificationService.onDidDismiss(id => { + if (id !== QUOTA_NOTIFICATION_ID) { + return; + } + if (this._activeQuotaNotificationKind === QuotaNotificationKind.Trajectory) { + this._storeTrajectoryDismissal(); + } + this._activeQuotaNotificationKind = QuotaNotificationKind.None; + })); // Re-evaluate when the selected model changes (e.g. switching between Copilot and BYOK). // The chatModelId context key is widget-scoped and may not bubble to the global @@ -70,6 +119,17 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo })); // Check initial state in case quota is already exhausted at startup + this._updateTrajectoryTreatment(); + this._update(); + } + + private async _updateTrajectoryTreatment(): Promise { + const trajectoryTreatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); + const trajectoryEnabled = trajectoryTreatment === 'enabled'; + if (this._trajectoryNudgeEnabled === trajectoryEnabled) { + return; + } + this._trajectoryNudgeEnabled = trajectoryEnabled; this._update(); } @@ -148,6 +208,12 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo // Priority 2: Quota approaching threshold if (isQuotaNotificationEligible) { + const trajectoryWarning = this._computeQuotaTrajectoryWarning(); + if (trajectoryWarning) { + this._showQuotaTrajectoryWarning(trajectoryWarning); + return; + } + const quotaWarning = this._computeQuotaWarning(); if (quotaWarning) { this._showQuotaApproachingWarning(quotaWarning); @@ -177,6 +243,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._prevQuotaPercentUsed = undefined; return undefined; } + const percentUsed = 100 - snapshot.percentRemaining; const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); this._prevQuotaPercentUsed = percentUsed; @@ -186,6 +253,92 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } + private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined { + if (!this._trajectoryNudgeEnabled || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryDismissed()) { + return undefined; + } + + const snapshot = this._getRelevantSnapshot(); + if (!snapshot || snapshot.unlimited || snapshot.percentRemaining <= 0) { + return undefined; + } + + const resetDate = this._chatEntitlementService.quotas.resetDate; + if (!resetDate) { + return undefined; + } + + const resetTime = new Date(resetDate).getTime(); + if (!Number.isFinite(resetTime)) { + return undefined; + } + + const periodStartTime = resetTime - (30 * 24 * 60 * 60 * 1000); + const elapsedDays = Math.max(0, (Date.now() - periodStartTime) / (24 * 60 * 60 * 1000)); + if (elapsedDays <= 0) { + return undefined; + } + + const percentUsed = 100 - snapshot.percentRemaining; + if (percentUsed < TRAJECTORY_MINIMUM_PERCENT_USED || percentUsed > TRAJECTORY_MAXIMUM_PERCENT_USED) { + return undefined; + } + + const averageDailyUsage = percentUsed / elapsedDays; + if (averageDailyUsage >= TRAJECTORY_DAILY_USAGE_THRESHOLD) { + return { averageDailyUsage, percentUsed }; + } + return undefined; + } + + private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { + this._showingExhausted = false; + this._activeQuotaNotificationKind = QuotaNotificationKind.Trajectory; + this._logQuotaTrajectoryNudgeShown(warning); + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message: localize('quota.trajectory.title', "Fast Credit Usage"), + description: localize('quota.trajectory.desc', "Based on recent usage, your monthly allowance may run out before it resets."), + actions: [{ + label: localize('quota.trajectory.learnMore', "Review Credit Tips"), + commandId: 'vscode.open', + commandArgs: [URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)], + secondary: true, + run: () => this._logQuotaTrajectoryNudgeActionClicked(warning, 'learnMore'), + }], + dismissible: true, + autoDismissOnMessage: false, + }); + } + + private _logQuotaTrajectoryNudgeShown(warning: { averageDailyUsage: number; percentUsed: number }): void { + const resetPeriod = this._getTrajectoryPeriodKey(); + const signature = resetPeriod ?? 'unknown'; + if (signature === this._lastLoggedTrajectoryShownSignature) { + return; + } + this._lastLoggedTrajectoryShownSignature = signature; + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', this._getQuotaTrajectoryNudgeTelemetryData(warning)); + } + + private _logQuotaTrajectoryNudgeActionClicked(warning: { averageDailyUsage: number; percentUsed: number }, action: ChatQuotaTrajectoryNudgeAction): void { + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeActionClicked', { + ...this._getQuotaTrajectoryNudgeTelemetryData(warning), + action, + }); + } + + private _getQuotaTrajectoryNudgeTelemetryData(warning: { averageDailyUsage: number; percentUsed: number }): ChatQuotaTrajectoryNudgeEvent { + return { + severity: 'info', + entitlement: ChatEntitlement[this._chatEntitlementService.entitlement], + averageDailyUsage: Math.round(warning.averageDailyUsage * 100) / 100, + percentUsed: Math.round(warning.percentUsed * 100) / 100, + }; + } + /** * Returns the highest threshold that was newly crossed, or `undefined`. */ @@ -206,6 +359,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showExhaustedNotification(): void { this._showingExhausted = true; + this._activeQuotaNotificationKind = QuotaNotificationKind.None; const entitlement = this._chatEntitlementService.entitlement; const quotas = this._chatEntitlementService.quotas; @@ -247,6 +401,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showOverageActivationNotification(): void { this._showingExhausted = true; + this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._setNotification({ id: QUOTA_NOTIFICATION_ID, @@ -264,6 +419,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { this._showingExhausted = false; + this._activeQuotaNotificationKind = QuotaNotificationKind.None; const entitlement = this._chatEntitlementService.entitlement; const quotas = this._chatEntitlementService.quotas; @@ -336,6 +492,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showRateLimitWarning(warning: { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined }): void { this._showingExhausted = false; + this._activeQuotaNotificationKind = QuotaNotificationKind.None; const message = warning.type === 'session' ? localize('rateLimit.session', "You've used {0}% of your session rate limit.", warning.percentUsed) @@ -372,6 +529,11 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return entitlement === ChatEntitlement.Business || entitlement === ChatEntitlement.Enterprise; } + private _isTrajectoryEligibleEntitlement(): boolean { + const entitlement = this._chatEntitlementService.entitlement; + return entitlement === ChatEntitlement.EDU || entitlement === ChatEntitlement.Pro || entitlement === ChatEntitlement.ProPlus; + } + private _isManagedPlanBlocked(): boolean { const snapshot = this._chatEntitlementService.quotas.premiumChat; return !!snapshot && snapshot.hasQuota === false; @@ -379,6 +541,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showManagedPlanBlockedNotification(): void { this._showingExhausted = true; + this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._setNotification({ id: QUOTA_NOTIFICATION_ID, @@ -402,12 +565,38 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo ).value.format(resetDate); } + private _getTrajectoryPeriodKey(): string | undefined { + const resetDate = this._chatEntitlementService.quotas.resetDate; + if (!resetDate) { + return undefined; + } + const date = new Date(resetDate); + if (!Number.isFinite(date.getTime())) { + return undefined; + } + return `${date.getUTCFullYear()}-${date.getUTCMonth() + 1}`; + } + + private _isTrajectoryDismissed(): boolean { + const periodKey = this._getTrajectoryPeriodKey(); + return !!periodKey && this._storageService.get(TRAJECTORY_DISMISSED_STORAGE_KEY, StorageScope.APPLICATION) === periodKey; + } + + private _storeTrajectoryDismissal(): void { + const periodKey = this._getTrajectoryPeriodKey(); + if (periodKey) { + this._storageService.store(TRAJECTORY_DISMISSED_STORAGE_KEY, periodKey, StorageScope.APPLICATION, StorageTarget.USER); + } + } + private _setNotification(notification: IChatInputNotification): void { this._chatInputNotificationService.setNotification(notification); } private _hideNotification(): void { this._showingExhausted = false; + this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); } + } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts index bc4f1c2791b951..a229edb0600176 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts @@ -21,6 +21,8 @@ export interface IChatInputNotificationAction { readonly label: string; readonly commandId: string; readonly commandArgs?: unknown[]; + readonly secondary?: boolean; + readonly run?: () => void; } export interface IChatInputNotificationMuteAction { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts index 57e1488dece95f..1eccab331be6bf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts @@ -205,11 +205,11 @@ export class ChatInputNotificationWidget extends Disposable { for (let i = 0; i < notification.actions.length; i++) { const action = notification.actions[i]; - const isLast = i === notification.actions.length - 1; + const isSecondary = action.secondary ?? i !== notification.actions.length - 1; const button = this._contentDisposables.add(new Button(actionsContainer, { ...defaultButtonStyles, - ...(!isLast ? { + ...(isSecondary ? { buttonBackground: undefined, buttonHoverBackground: undefined, buttonForeground: undefined, @@ -219,7 +219,7 @@ export class ChatInputNotificationWidget extends Disposable { buttonSecondaryBorder: undefined, } : {}), supportIcons: true, - secondary: !isLast, + secondary: isSecondary, })); button.element.classList.add('chat-input-notification-action-button'); button.label = action.label; @@ -230,6 +230,7 @@ export class ChatInputNotificationWidget extends Disposable { id: action.commandId, from: 'chatInputNotification', }); + action.run?.(); await this._commandService.executeCommand(action.commandId, ...(action.commandArgs ?? [])); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css index 5863b79faaa134..4abdfbc1bfbf3d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css @@ -46,13 +46,13 @@ } .interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification.severity-warning { - border-color: var(--vscode-editorWarning-foreground); - background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 6%, var(--vscode-editorWidget-background)); + border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 55%, transparent); + background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 4%, var(--vscode-editorWidget-background)); } .interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification.severity-error { - border-color: var(--vscode-editorError-foreground); - background-color: color-mix(in srgb, var(--vscode-editorError-foreground) 6%, var(--vscode-editorWidget-background)); + border-color: color-mix(in srgb, var(--vscode-editorError-foreground) 55%, transparent); + background-color: color-mix(in srgb, var(--vscode-editorError-foreground) 4%, var(--vscode-editorWidget-background)); } /* Header row: icon + title + dismiss */ @@ -75,11 +75,11 @@ } .chat-input-notification.severity-warning .chat-input-notification-icon { - color: var(--vscode-editorWarning-foreground); + color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 80%, var(--vscode-foreground)); } .chat-input-notification.severity-error .chat-input-notification-icon { - color: var(--vscode-editorError-foreground); + color: color-mix(in srgb, var(--vscode-editorError-foreground) 80%, var(--vscode-foreground)); } /* Title */ @@ -117,6 +117,7 @@ /* Body row: description + actions inline, wraps at small widths */ .chat-input-notification .chat-input-notification-body { display: flex; + flex-wrap: wrap; align-items: flex-start; gap: 8px; min-width: 0; @@ -128,7 +129,7 @@ font-size: var(--vscode-agents-fontSize-label1); line-height: 18px; color: var(--vscode-descriptionForeground); - flex: 1 1 auto; + flex: 1 1 160px; min-width: 0; overflow-wrap: break-word; word-break: break-word; @@ -155,16 +156,14 @@ /* Transparent ghost style for secondary action buttons (e.g. "View Usage") */ .chat-input-notification .chat-input-notification-action-button.secondary { - border: none; - background: transparent; + border: 1px solid var(--vscode-button-border, transparent); + background: color-mix(in srgb, var(--vscode-button-secondaryBackground) 65%, transparent); color: var(--vscode-foreground); padding: 4px 8px; - opacity: 0.8; } .chat-input-notification .chat-input-notification-action-button.secondary:hover { - background: var(--vscode-toolbar-hoverBackground); - opacity: 1; + background: var(--vscode-button-secondaryHoverBackground); } /* Dismiss + mute icon buttons (header, right-aligned) */ diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 81108bd53e280a..02202f436c74a2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -10,15 +10,20 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { ChatEntitlement, IChatEntitlementService, IChatSentiment, IQuotaSnapshot, IRateLimitSnapshot } from '../../../../services/chat/common/chatEntitlementService.js'; import { ChatQuotaNotificationContribution } from '../../browser/chatQuotaNotification.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../common/languageModels.js'; -import { IChatInputNotification, IChatInputNotificationService } from '../../browser/widget/input/chatInputNotificationService.js'; +import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../browser/widget/input/chatInputNotificationService.js'; // --- Mock IChatEntitlementService ------------------------------------------- interface IMockQuotas { + resetDate?: string; usageBasedBilling?: boolean; + canUpgradePlan?: boolean; chat?: IQuotaSnapshot; completions?: IQuotaSnapshot; premiumChat?: IQuotaSnapshot; @@ -45,7 +50,9 @@ function createMockEntitlementService(opts?: { onDidChangeQuotaRemaining: onDidChangeQuotaRemaining.event, onDidChangeUsageBasedBilling: Event.None, quotas: { + resetDate: opts?.quotas?.resetDate, usageBasedBilling: opts?.quotas?.usageBasedBilling ?? true, + canUpgradePlan: opts?.quotas?.canUpgradePlan, chat: opts?.quotas?.chat, completions: opts?.quotas?.completions, premiumChat: opts?.quotas?.premiumChat, @@ -127,10 +134,38 @@ function createMockNotificationService() { getNotification(): IChatInputNotification | undefined { return deleted || dismissed ? undefined : lastNotification; }, get wasDeleted() { return deleted; }, get setCount() { return setCount; }, + dismiss() { + if (lastNotification) { + service.dismissNotification(lastNotification.id); + } + }, reset() { lastNotification = undefined; deleted = false; dismissed = false; setCount = 0; }, }; } +function createMockAssignmentService(treatments?: Readonly>) { + const onDidRefetchAssignments = new Emitter(); + const service: IWorkbenchAssignmentService = { + _serviceBrand: undefined, + onDidRefetchAssignments: onDidRefetchAssignments.event, + getTreatment(name: string) { return Promise.resolve(treatments?.[name]); }, + getCurrentExperiments() { return Promise.resolve(undefined); }, + addTelemetryAssignmentFilter() { }, + } as unknown as IWorkbenchAssignmentService; + + return { service, onDidRefetchAssignments }; +} + +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { name: string; data: unknown }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } +} + // --- Helpers --------------------------------------------------------------- function makeQuotaSnapshot(percentRemaining: number, opts?: Partial): IQuotaSnapshot { @@ -141,6 +176,10 @@ function makeQuotaSnapshot(percentRemaining: number, opts?: Partial { + await new Promise(resolve => setTimeout(resolve, 0)); +} + function makeRateLimitSnapshot(percentRemaining: number, opts?: Partial): IRateLimitSnapshot { return { percentRemaining, @@ -150,15 +189,23 @@ function makeRateLimitSnapshot(percentRemaining: number, opts?: Partial { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string }) { + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string; trajectoryTreatment?: string; telemetryService?: ITelemetryService }) { const entitlementMock = createMockEntitlementService(entitlementOpts); const notificationMock = createMockNotificationService(); + const assignmentMock = createMockAssignmentService({ + chatQuotaTrajectoryNudge: modelOpts?.trajectoryTreatment, + }); const contextKeyService = store.add(new MockContextKeyService()); const storageService = store.add(new InMemoryStorageService()); const vendor = modelOpts?.vendor ?? 'copilot'; @@ -179,6 +226,7 @@ suite('ChatQuotaNotificationContribution', () => { store.add(entitlementMock.onDidChangeQuotaRemaining); store.add(entitlementMock.onDidChangeQuotaExceeded); store.add(entitlementMock.onDidChangeEntitlement); + store.add(assignmentMock.onDidRefetchAssignments); const contribution = store.add(new ChatQuotaNotificationContribution( entitlementMock.service, @@ -186,9 +234,11 @@ suite('ChatQuotaNotificationContribution', () => { contextKeyService as IContextKeyService, languageModelsService, storageService, + assignmentMock.service, + modelOpts?.telemetryService ?? new NullTelemetryServiceShape(), )); - return { contribution, entitlementMock, notificationMock, storageService }; + return { contribution, entitlementMock, notificationMock, storageService, assignmentMock }; } function updateQuotas( @@ -479,6 +529,208 @@ suite('ChatQuotaNotificationContribution', () => { }); }); + // --- Quota trajectory warning -------------------------------------------- + + suite('quota trajectory warning', () => { + test('does not show when experiment treatment is disabled', async () => { + const { notificationMock } = createContribution({ + quotas: { + resetDate: makeResetDate(24), + canUpgradePlan: true, + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(75), + }, + }); + + await flushPromises(); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('does not show outside monthly usage window', async () => { + const results = []; + for (const percentRemaining of [91, 64]) { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(percentRemaining), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + results.push(notificationMock.getNotification()?.message); + } + + assert.deepStrictEqual(results, [undefined, undefined]); + }); + + test('shows info notification when projected daily usage is above threshold', async () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(78), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + assert.ok(notificationMock.getNotification()); + assert.deepStrictEqual({ + message: notificationMock.getNotification()!.message, + severity: notificationMock.getNotification()!.severity, + action: notificationMock.getNotification()!.actions[0].label, + autoDismissOnMessage: notificationMock.getNotification()!.autoDismissOnMessage, + }, { + message: 'Fast Credit Usage', + severity: ChatInputNotificationSeverity.Info, + action: 'Review Credit Tips', + autoDismissOnMessage: false, + }); + }); + + test('does not show when reset date implies no elapsed billing days', async () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(31), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(78), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('logs shown telemetry once per quota period', async () => { + const telemetryService = new TestTelemetryService(); + const { entitlementMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(78), + }, + }, { trajectoryTreatment: 'enabled', telemetryService }); + + await flushPromises(); + entitlementMock.onDidChangeQuotaRemaining.fire(); + + assert.deepStrictEqual(telemetryService.events, [{ + name: 'chatQuotaTrajectoryNudgeShown', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 3.67, + percentUsed: 22, + }, + }]); + }); + + test('logs action click telemetry', async () => { + const telemetryService = new TestTelemetryService(); + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(78), + }, + }, { trajectoryTreatment: 'enabled', telemetryService }); + + await flushPromises(); + notificationMock.getNotification()!.actions[0].run?.(); + + assert.deepStrictEqual(telemetryService.events, [ + { + name: 'chatQuotaTrajectoryNudgeShown', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 3.67, + percentUsed: 22, + }, + }, + { + name: 'chatQuotaTrajectoryNudgeActionClicked', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 3.67, + percentUsed: 22, + action: 'learnMore', + }, + }, + ]); + }); + + test('uses info severity even when projected daily usage is high', async () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.ProPlus, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + assert.ok(notificationMock.getNotification()); + assert.deepStrictEqual({ + message: notificationMock.getNotification()!.message, + severity: notificationMock.getNotification()!.severity, + }, { + message: 'Fast Credit Usage', + severity: ChatInputNotificationSeverity.Info, + }); + }); + + test('remembers trajectory dismissal for the quota period', async () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.EDU, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + assert.ok(notificationMock.getNotification()); + + notificationMock.dismiss(); + notificationMock.reset(); + entitlementMock.onDidChangeQuotaRemaining.fire(); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('does not show for Max, Business, Enterprise, Free, or Unknown users', async () => { + for (const entitlement of [ChatEntitlement.Max, ChatEntitlement.Business, ChatEntitlement.Enterprise, ChatEntitlement.Free, ChatEntitlement.Unknown]) { + const { notificationMock } = createContribution({ + entitlement, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + chat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + assert.strictEqual(notificationMock.getNotification(), undefined, `Expected no trajectory notification for ${entitlement}`); + } + }); + }); + // --- Rate-limit warnings ------------------------------------------------ suite('rate-limit warnings', () => { @@ -624,6 +876,7 @@ suite('ChatQuotaNotificationContribution', () => { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, }); const notificationMock = createMockNotificationService(); + const assignmentMock = createMockAssignmentService(); const contextKeyService = store.add(new MockContextKeyService()); const storageService = store.add(new InMemoryStorageService()); // Start with BYOK model @@ -642,6 +895,7 @@ suite('ChatQuotaNotificationContribution', () => { store.add(entitlementMock.onDidChangeQuotaRemaining); store.add(entitlementMock.onDidChangeQuotaExceeded); store.add(entitlementMock.onDidChangeEntitlement); + store.add(assignmentMock.onDidRefetchAssignments); store.add(new ChatQuotaNotificationContribution( entitlementMock.service, @@ -649,6 +903,8 @@ suite('ChatQuotaNotificationContribution', () => { contextKeyService as IContextKeyService, languageModelsService, storageService, + assignmentMock.service, + new NullTelemetryServiceShape(), )); // Initially deferred — BYOK model From 50849ff9cee541df761270ffb7afaf238d1c9144 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Tue, 9 Jun 2026 15:13:56 -0700 Subject: [PATCH 02/24] Limit chat input notification CSS changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../media/chatInputNotificationWidget.css | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css index 4abdfbc1bfbf3d..2b8f4aac2122e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css @@ -46,13 +46,13 @@ } .interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification.severity-warning { - border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 55%, transparent); - background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 4%, var(--vscode-editorWidget-background)); + border-color: var(--vscode-editorWarning-foreground); + background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 6%, var(--vscode-editorWidget-background)); } .interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification.severity-error { - border-color: color-mix(in srgb, var(--vscode-editorError-foreground) 55%, transparent); - background-color: color-mix(in srgb, var(--vscode-editorError-foreground) 4%, var(--vscode-editorWidget-background)); + border-color: var(--vscode-editorError-foreground); + background-color: color-mix(in srgb, var(--vscode-editorError-foreground) 6%, var(--vscode-editorWidget-background)); } /* Header row: icon + title + dismiss */ @@ -75,11 +75,11 @@ } .chat-input-notification.severity-warning .chat-input-notification-icon { - color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 80%, var(--vscode-foreground)); + color: var(--vscode-editorWarning-foreground); } .chat-input-notification.severity-error .chat-input-notification-icon { - color: color-mix(in srgb, var(--vscode-editorError-foreground) 80%, var(--vscode-foreground)); + color: var(--vscode-editorError-foreground); } /* Title */ @@ -156,14 +156,16 @@ /* Transparent ghost style for secondary action buttons (e.g. "View Usage") */ .chat-input-notification .chat-input-notification-action-button.secondary { - border: 1px solid var(--vscode-button-border, transparent); - background: color-mix(in srgb, var(--vscode-button-secondaryBackground) 65%, transparent); + border: none; + background: transparent; color: var(--vscode-foreground); padding: 4px 8px; + opacity: 0.8; } .chat-input-notification .chat-input-notification-action-button.secondary:hover { - background: var(--vscode-button-secondaryHoverBackground); + background: var(--vscode-toolbar-hoverBackground); + opacity: 1; } /* Dismiss + mute icon buttons (header, right-aligned) */ From 138b83d895dfc368f8cc3e497afcc1bda4290634 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Tue, 9 Jun 2026 15:41:07 -0700 Subject: [PATCH 03/24] Dismiss quota trajectory nudge on action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 8 ++++++- .../browser/chatQuotaNotification.test.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index c0aa7f2dce6881..81953db1c07067 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -306,13 +306,19 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo commandId: 'vscode.open', commandArgs: [URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)], secondary: true, - run: () => this._logQuotaTrajectoryNudgeActionClicked(warning, 'learnMore'), + run: () => this._handleQuotaTrajectoryNudgeAction(warning, 'learnMore'), }], dismissible: true, autoDismissOnMessage: false, }); } + private _handleQuotaTrajectoryNudgeAction(warning: { averageDailyUsage: number; percentUsed: number }, action: ChatQuotaTrajectoryNudgeAction): void { + this._logQuotaTrajectoryNudgeActionClicked(warning, action); + this._storeTrajectoryDismissal(); + queueMicrotask(() => this._hideNotification()); + } + private _logQuotaTrajectoryNudgeShown(warning: { averageDailyUsage: number; percentUsed: number }): void { const resetPeriod = this._getTrajectoryPeriodKey(); const signature = resetPeriod ?? 'unknown'; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 02202f436c74a2..eb6d45056b4778 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -670,6 +670,30 @@ suite('ChatQuotaNotificationContribution', () => { ]); }); + test('action click dismisses trajectory nudge for the quota period', async () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(78), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + assert.ok(notificationMock.getNotification()); + + notificationMock.getNotification()!.actions[0].run?.(); + await flushPromises(); + + assert.strictEqual(notificationMock.getNotification(), undefined); + + notificationMock.reset(); + entitlementMock.onDidChangeQuotaRemaining.fire(); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + test('uses info severity even when projected daily usage is high', async () => { const { notificationMock } = createContribution({ entitlement: ChatEntitlement.ProPlus, From f1c8d3601a2b01ab03350d2d7c6718fd126a4f6f Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Tue, 9 Jun 2026 15:44:20 -0700 Subject: [PATCH 04/24] Trim redundant quota trajectory test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/chatQuotaNotification.test.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index eb6d45056b4778..41010742c182e0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -694,28 +694,6 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('uses info severity even when projected daily usage is high', async () => { - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.ProPlus, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: 'enabled' }); - - await flushPromises(); - - assert.ok(notificationMock.getNotification()); - assert.deepStrictEqual({ - message: notificationMock.getNotification()!.message, - severity: notificationMock.getNotification()!.severity, - }, { - message: 'Fast Credit Usage', - severity: ChatInputNotificationSeverity.Info, - }); - }); - test('remembers trajectory dismissal for the quota period', async () => { const { entitlementMock, notificationMock } = createContribution({ entitlement: ChatEntitlement.EDU, From 909379961884e68c9f5cf4579bf56040621d0b63 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Tue, 16 Jun 2026 15:39:56 -0700 Subject: [PATCH 05/24] Update quota trajectory notification link Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 41 +++++-- .../browser/chatQuotaNotification.test.ts | 111 +++++++++++++----- 2 files changed, 112 insertions(+), 40 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 81953db1c07067..014b06dbfc09f6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -4,10 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { safeIntl } from '../../../../base/common/date.js'; +import { createMarkdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; @@ -24,7 +28,8 @@ const TRAJECTORY_MINIMUM_PERCENT_USED = 10; const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; const TRAJECTORY_TREATMENT = 'chatQuotaTrajectoryNudge'; const TRAJECTORY_DISMISSED_STORAGE_KEY = 'chat.quotaTrajectory.dismissedPeriod'; -const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://www.microsoft.com'; +const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; +const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; const enum QuotaNotificationKind { None, @@ -82,6 +87,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _trajectoryNudgeEnabled = false; private _activeQuotaNotificationKind = QuotaNotificationKind.None; private _lastLoggedTrajectoryShownSignature: string | undefined; + private _activeTrajectoryWarning: { averageDailyUsage: number; percentUsed: number } | undefined; constructor( @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @@ -106,7 +112,9 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._storeTrajectoryDismissal(); } this._activeQuotaNotificationKind = QuotaNotificationKind.None; + this._activeTrajectoryWarning = undefined; })); + this._register(CommandsRegistry.registerCommand(CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, (accessor: ServicesAccessor) => this._handleCreditEfficiencyLearnMoreCommand(accessor))); // Re-evaluate when the selected model changes (e.g. switching between Copilot and BYOK). // The chatModelId context key is widget-scoped and may not bubble to the global @@ -294,25 +302,32 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.Trajectory; + this._activeTrajectoryWarning = warning; this._logQuotaTrajectoryNudgeShown(warning); + const learnMoreLink = createMarkdownCommandLink({ + text: localize('quota.trajectory.learnMore', "Learn more about managing credits"), + id: CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, + tooltip: localize('quota.trajectory.learnMoreTooltip', "Learn more about managing credits"), + }); this._setNotification({ id: QUOTA_NOTIFICATION_ID, severity: ChatInputNotificationSeverity.Info, - message: localize('quota.trajectory.title', "Fast Credit Usage"), - description: localize('quota.trajectory.desc', "Based on recent usage, your monthly allowance may run out before it resets."), - actions: [{ - label: localize('quota.trajectory.learnMore', "Review Credit Tips"), - commandId: 'vscode.open', - commandArgs: [URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)], - secondary: true, - run: () => this._handleQuotaTrajectoryNudgeAction(warning, 'learnMore'), - }], + message: new MarkdownString(localize({ key: 'quota.trajectory.message', comment: ['{Locked="]({0})"}'] }, "Based on recent usage, your monthly allowance may run out before it resets. {0}", learnMoreLink), { isTrusted: { enabledCommands: [CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID] } }), + description: undefined, + actions: [], dismissible: true, autoDismissOnMessage: false, }); } + private async _handleCreditEfficiencyLearnMoreCommand(accessor: ServicesAccessor): Promise { + if (this._activeQuotaNotificationKind === QuotaNotificationKind.Trajectory && this._activeTrajectoryWarning) { + this._handleQuotaTrajectoryNudgeAction(this._activeTrajectoryWarning, 'learnMore'); + } + await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); + } + private _handleQuotaTrajectoryNudgeAction(warning: { averageDailyUsage: number; percentUsed: number }, action: ChatQuotaTrajectoryNudgeAction): void { this._logQuotaTrajectoryNudgeActionClicked(warning, action); this._storeTrajectoryDismissal(); @@ -366,6 +381,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showExhaustedNotification(): void { this._showingExhausted = true; this._activeQuotaNotificationKind = QuotaNotificationKind.None; + this._activeTrajectoryWarning = undefined; const entitlement = this._chatEntitlementService.entitlement; const quotas = this._chatEntitlementService.quotas; @@ -408,6 +424,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showOverageActivationNotification(): void { this._showingExhausted = true; this._activeQuotaNotificationKind = QuotaNotificationKind.None; + this._activeTrajectoryWarning = undefined; this._setNotification({ id: QUOTA_NOTIFICATION_ID, @@ -426,6 +443,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.None; + this._activeTrajectoryWarning = undefined; const entitlement = this._chatEntitlementService.entitlement; const quotas = this._chatEntitlementService.quotas; @@ -499,6 +517,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showRateLimitWarning(warning: { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined }): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.None; + this._activeTrajectoryWarning = undefined; const message = warning.type === 'session' ? localize('rateLimit.session', "You've used {0}% of your session rate limit.", warning.percentUsed) @@ -548,6 +567,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showManagedPlanBlockedNotification(): void { this._showingExhausted = true; this._activeQuotaNotificationKind = QuotaNotificationKind.None; + this._activeTrajectoryWarning = undefined; this._setNotification({ id: QUOTA_NOTIFICATION_ID, @@ -602,6 +622,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _hideNotification(): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.None; + this._activeTrajectoryWarning = undefined; this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 41010742c182e0..6f1b8267eb811f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -5,10 +5,16 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { createMarkdownCommandLink } from '../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServiceIdentifier, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; @@ -18,6 +24,9 @@ import { ChatQuotaNotificationContribution } from '../../browser/chatQuotaNotifi import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../common/languageModels.js'; import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../browser/widget/input/chatInputNotificationService.js'; +const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; +const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; + // --- Mock IChatEntitlementService ------------------------------------------- interface IMockQuotas { @@ -189,6 +198,35 @@ function makeRateLimitSnapshot(percentRemaining: number, opts?: Partial { + const opened: URI[] = []; + const openerService: IOpenerService = { + _serviceBrand: undefined, + registerOpener: () => Disposable.None, + registerValidator: () => Disposable.None, + registerExternalUriResolver: () => Disposable.None, + setDefaultExternalOpener: () => { }, + registerExternalOpener: () => Disposable.None, + open: async resource => { + opened.push(URI.isUri(resource) ? resource : URI.parse(resource)); + return true; + }, + resolveExternalUri: async resource => ({ resolved: resource, dispose: () => { } }), + }; + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => { + if (id === IOpenerService) { + return openerService as T; + } + throw new Error('Unexpected service'); + }, + }; + const command = CommandsRegistry.getCommand(CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID); + assert.ok(command); + await command.handler(accessor); + return opened; +} + function makeResetDate(daysUntilReset: number): string { const resetDate = new Date(Date.now() + daysUntilReset * 24 * 60 * 60 * 1000); return resetDate.toISOString(); @@ -579,16 +617,23 @@ suite('ChatQuotaNotificationContribution', () => { await flushPromises(); - assert.ok(notificationMock.getNotification()); + const notification = notificationMock.getNotification(); + assert.ok(notification); + const message = notification.message; + const learnMoreLink = createMarkdownCommandLink({ + text: 'Learn more about managing credits', + id: CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, + tooltip: 'Learn more about managing credits', + }); assert.deepStrictEqual({ - message: notificationMock.getNotification()!.message, - severity: notificationMock.getNotification()!.severity, - action: notificationMock.getNotification()!.actions[0].label, - autoDismissOnMessage: notificationMock.getNotification()!.autoDismissOnMessage, + message: typeof message === 'string' ? message : message.value, + severity: notification.severity, + actions: notification.actions.length, + autoDismissOnMessage: notification.autoDismissOnMessage, }, { - message: 'Fast Credit Usage', + message: `Based on recent usage, your monthly allowance may run out before it resets. ${learnMoreLink}`, severity: ChatInputNotificationSeverity.Info, - action: 'Review Credit Tips', + actions: 0, autoDismissOnMessage: false, }); }); @@ -635,7 +680,7 @@ suite('ChatQuotaNotificationContribution', () => { test('logs action click telemetry', async () => { const telemetryService = new TestTelemetryService(); - const { notificationMock } = createContribution({ + createContribution({ entitlement: ChatEntitlement.Pro, quotas: { resetDate: makeResetDate(24), @@ -645,29 +690,35 @@ suite('ChatQuotaNotificationContribution', () => { }, { trajectoryTreatment: 'enabled', telemetryService }); await flushPromises(); - notificationMock.getNotification()!.actions[0].run?.(); - - assert.deepStrictEqual(telemetryService.events, [ - { - name: 'chatQuotaTrajectoryNudgeShown', - data: { - severity: 'info', - entitlement: 'Pro', - averageDailyUsage: 3.67, - percentUsed: 22, + const opened = await runCreditEfficiencyLearnMoreCommand(); + + assert.deepStrictEqual({ + events: telemetryService.events, + opened: opened.map(uri => uri.toString()), + }, { + events: [ + { + name: 'chatQuotaTrajectoryNudgeShown', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 3.67, + percentUsed: 22, + }, }, - }, - { - name: 'chatQuotaTrajectoryNudgeActionClicked', - data: { - severity: 'info', - entitlement: 'Pro', - averageDailyUsage: 3.67, - percentUsed: 22, - action: 'learnMore', + { + name: 'chatQuotaTrajectoryNudgeActionClicked', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 3.67, + percentUsed: 22, + action: 'learnMore', + }, }, - }, - ]); + ], + opened: [CREDIT_EFFICIENCY_LEARN_MORE_URL], + }); }); test('action click dismisses trajectory nudge for the quota period', async () => { @@ -683,7 +734,7 @@ suite('ChatQuotaNotificationContribution', () => { await flushPromises(); assert.ok(notificationMock.getNotification()); - notificationMock.getNotification()!.actions[0].run?.(); + await runCreditEfficiencyLearnMoreCommand(); await flushPromises(); assert.strictEqual(notificationMock.getNotification(), undefined); From 635920c550f757241a432e73e112fdf335e1fc82 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 17 Jun 2026 09:50:59 -0700 Subject: [PATCH 06/24] Align quota trajectory nudge requirements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 19 +++--- .../media/chatInputNotificationWidget.css | 10 +++ .../browser/chatQuotaNotification.test.ts | 64 ++++++++++++++----- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 014b06dbfc09f6..2020094b593c13 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -23,11 +23,11 @@ import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotifi const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; const THRESHOLDS = [50, 75, 90, 95]; -const TRAJECTORY_DAILY_USAGE_THRESHOLD = 3.5; +const TRAJECTORY_DAILY_USAGE_THRESHOLD = 4.5; const TRAJECTORY_MINIMUM_PERCENT_USED = 10; const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; const TRAJECTORY_TREATMENT = 'chatQuotaTrajectoryNudge'; -const TRAJECTORY_DISMISSED_STORAGE_KEY = 'chat.quotaTrajectory.dismissedPeriod'; +const TRAJECTORY_SHOWN_STORAGE_KEY = 'chat.quotaTrajectory.shownPeriod'; const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; @@ -109,7 +109,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return; } if (this._activeQuotaNotificationKind === QuotaNotificationKind.Trajectory) { - this._storeTrajectoryDismissal(); + this._storeTrajectoryShown(); } this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; @@ -262,7 +262,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined { - if (!this._trajectoryNudgeEnabled || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryDismissed()) { + if (!this._trajectoryNudgeEnabled || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryShownInCurrentPeriod()) { return undefined; } @@ -304,6 +304,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._activeQuotaNotificationKind = QuotaNotificationKind.Trajectory; this._activeTrajectoryWarning = warning; this._logQuotaTrajectoryNudgeShown(warning); + this._storeTrajectoryShown(); const learnMoreLink = createMarkdownCommandLink({ text: localize('quota.trajectory.learnMore', "Learn more about managing credits"), id: CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, @@ -330,7 +331,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _handleQuotaTrajectoryNudgeAction(warning: { averageDailyUsage: number; percentUsed: number }, action: ChatQuotaTrajectoryNudgeAction): void { this._logQuotaTrajectoryNudgeActionClicked(warning, action); - this._storeTrajectoryDismissal(); + this._storeTrajectoryShown(); queueMicrotask(() => this._hideNotification()); } @@ -603,15 +604,15 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return `${date.getUTCFullYear()}-${date.getUTCMonth() + 1}`; } - private _isTrajectoryDismissed(): boolean { + private _isTrajectoryShownInCurrentPeriod(): boolean { const periodKey = this._getTrajectoryPeriodKey(); - return !!periodKey && this._storageService.get(TRAJECTORY_DISMISSED_STORAGE_KEY, StorageScope.APPLICATION) === periodKey; + return !!periodKey && this._storageService.get(TRAJECTORY_SHOWN_STORAGE_KEY, StorageScope.APPLICATION) === periodKey; } - private _storeTrajectoryDismissal(): void { + private _storeTrajectoryShown(): void { const periodKey = this._getTrajectoryPeriodKey(); if (periodKey) { - this._storageService.store(TRAJECTORY_DISMISSED_STORAGE_KEY, periodKey, StorageScope.APPLICATION, StorageTarget.USER); + this._storageService.store(TRAJECTORY_SHOWN_STORAGE_KEY, periodKey, StorageScope.APPLICATION, StorageTarget.USER); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css index 2b8f4aac2122e6..07a62649cf8265 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css @@ -106,6 +106,16 @@ margin: 0; } +.chat-input-notification .chat-input-notification-title-markdown a, +.chat-input-notification .chat-input-notification-title-markdown a:visited { + color: var(--vscode-textLink-foreground); +} + +.chat-input-notification .chat-input-notification-title-markdown a:hover, +.chat-input-notification .chat-input-notification-title-markdown a:active { + color: var(--vscode-textLink-activeForeground); +} + .chat-input-notification .chat-input-notification-title-markdown code { font-family: var(--monaco-monospace-font); font-size: var(--vscode-agents-fontSize-body2); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 6f1b8267eb811f..48411806174247 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -576,7 +576,7 @@ suite('ChatQuotaNotificationContribution', () => { resetDate: makeResetDate(24), canUpgradePlan: true, usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(75), + premiumChat: makeQuotaSnapshot(72), }, }); @@ -611,7 +611,7 @@ suite('ChatQuotaNotificationContribution', () => { quotas: { resetDate: makeResetDate(24), usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(78), + premiumChat: makeQuotaSnapshot(72), }, }, { trajectoryTreatment: 'enabled' }); @@ -638,13 +638,48 @@ suite('ChatQuotaNotificationContribution', () => { }); }); + test('shows for Edu and Pro+ users', async () => { + const results = []; + for (const entitlement of [ChatEntitlement.EDU, ChatEntitlement.ProPlus]) { + const { notificationMock } = createContribution({ + entitlement, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + results.push(!!notificationMock.getNotification()); + } + + assert.deepStrictEqual(results, [true, true]); + }); + + test('does not show when projected daily usage is below threshold', async () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(78), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + test('does not show when reset date implies no elapsed billing days', async () => { const { notificationMock } = createContribution({ entitlement: ChatEntitlement.Pro, quotas: { resetDate: makeResetDate(31), usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(78), + premiumChat: makeQuotaSnapshot(72), }, }, { trajectoryTreatment: 'enabled' }); @@ -660,7 +695,7 @@ suite('ChatQuotaNotificationContribution', () => { quotas: { resetDate: makeResetDate(24), usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(78), + premiumChat: makeQuotaSnapshot(72), }, }, { trajectoryTreatment: 'enabled', telemetryService }); @@ -672,8 +707,8 @@ suite('ChatQuotaNotificationContribution', () => { data: { severity: 'info', entitlement: 'Pro', - averageDailyUsage: 3.67, - percentUsed: 22, + averageDailyUsage: 4.67, + percentUsed: 28, }, }]); }); @@ -685,7 +720,7 @@ suite('ChatQuotaNotificationContribution', () => { quotas: { resetDate: makeResetDate(24), usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(78), + premiumChat: makeQuotaSnapshot(72), }, }, { trajectoryTreatment: 'enabled', telemetryService }); @@ -702,8 +737,8 @@ suite('ChatQuotaNotificationContribution', () => { data: { severity: 'info', entitlement: 'Pro', - averageDailyUsage: 3.67, - percentUsed: 22, + averageDailyUsage: 4.67, + percentUsed: 28, }, }, { @@ -711,8 +746,8 @@ suite('ChatQuotaNotificationContribution', () => { data: { severity: 'info', entitlement: 'Pro', - averageDailyUsage: 3.67, - percentUsed: 22, + averageDailyUsage: 4.67, + percentUsed: 28, action: 'learnMore', }, }, @@ -727,7 +762,7 @@ suite('ChatQuotaNotificationContribution', () => { quotas: { resetDate: makeResetDate(24), usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(78), + premiumChat: makeQuotaSnapshot(72), }, }, { trajectoryTreatment: 'enabled' }); @@ -745,9 +780,9 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('remembers trajectory dismissal for the quota period', async () => { + test('remembers trajectory display for the quota period', async () => { const { entitlementMock, notificationMock } = createContribution({ - entitlement: ChatEntitlement.EDU, + entitlement: ChatEntitlement.ProPlus, quotas: { resetDate: makeResetDate(24), usageBasedBilling: true, @@ -758,7 +793,6 @@ suite('ChatQuotaNotificationContribution', () => { await flushPromises(); assert.ok(notificationMock.getNotification()); - notificationMock.dismiss(); notificationMock.reset(); entitlementMock.onDidChangeQuotaRemaining.fire(); From da09f7addd51c843e0e711c4e82ef9d2803c46d6 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 17 Jun 2026 10:10:16 -0700 Subject: [PATCH 07/24] Exclude Edu from quota trajectory nudge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 2 +- .../browser/chatQuotaNotification.test.ts | 31 ++++++++----------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 2020094b593c13..805134ebf33842 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -557,7 +557,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _isTrajectoryEligibleEntitlement(): boolean { const entitlement = this._chatEntitlementService.entitlement; - return entitlement === ChatEntitlement.EDU || entitlement === ChatEntitlement.Pro || entitlement === ChatEntitlement.ProPlus; + return entitlement === ChatEntitlement.Pro || entitlement === ChatEntitlement.ProPlus; } private _isManagedPlanBlocked(): boolean { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 48411806174247..56d864443d9099 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -638,24 +638,19 @@ suite('ChatQuotaNotificationContribution', () => { }); }); - test('shows for Edu and Pro+ users', async () => { - const results = []; - for (const entitlement of [ChatEntitlement.EDU, ChatEntitlement.ProPlus]) { - const { notificationMock } = createContribution({ - entitlement, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: 'enabled' }); - - await flushPromises(); + test('shows for Pro+ users', async () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.ProPlus, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled' }); - results.push(!!notificationMock.getNotification()); - } + await flushPromises(); - assert.deepStrictEqual(results, [true, true]); + assert.ok(notificationMock.getNotification()); }); test('does not show when projected daily usage is below threshold', async () => { @@ -799,8 +794,8 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('does not show for Max, Business, Enterprise, Free, or Unknown users', async () => { - for (const entitlement of [ChatEntitlement.Max, ChatEntitlement.Business, ChatEntitlement.Enterprise, ChatEntitlement.Free, ChatEntitlement.Unknown]) { + test('does not show for Edu, Max, Business, Enterprise, Free, or Unknown users', async () => { + for (const entitlement of [ChatEntitlement.EDU, ChatEntitlement.Max, ChatEntitlement.Business, ChatEntitlement.Enterprise, ChatEntitlement.Free, ChatEntitlement.Unknown]) { const { notificationMock } = createContribution({ entitlement, quotas: { From f54179cbd5436320052f27248f06d6888a191262 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 17 Jun 2026 11:47:54 -0700 Subject: [PATCH 08/24] Instrument quota trajectory nudge telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 32 +++++++-------- .../browser/chatQuotaNotification.test.ts | 41 +++++++++++++++++-- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 805134ebf33842..7cea42b7b0528f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -36,24 +36,20 @@ const enum QuotaNotificationKind { Trajectory, } -type ChatQuotaTrajectoryNudgeAction = 'learnMore'; - type ChatQuotaTrajectoryNudgeEvent = { severity: 'info'; entitlement: string; averageDailyUsage: number; percentUsed: number; - action?: ChatQuotaTrajectoryNudgeAction; }; type ChatQuotaTrajectoryNudgeClassification = { owner: 'rfeltis'; - comment: 'Tracks when the chat quota trajectory nudge is shown and when users interact with its call to action.'; - severity: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The severity of the quota trajectory nudge shown to the user.' }; - entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user entitlement when the quota trajectory nudge was shown.' }; + comment: 'Tracks when the chat quota trajectory nudge is shown, closed, and when users click its learn more link.'; + severity: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The severity of the quota trajectory nudge.' }; + entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user entitlement when the quota trajectory nudge event was logged.' }; averageDailyUsage: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The average daily monthly quota usage percentage that caused the nudge.' }; - percentUsed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The monthly quota percentage used when the nudge was shown.' }; - action?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The quota trajectory nudge action the user selected.' }; + percentUsed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The monthly quota percentage used when the quota trajectory nudge event was logged.' }; }; /** @@ -109,6 +105,9 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return; } if (this._activeQuotaNotificationKind === QuotaNotificationKind.Trajectory) { + if (this._activeTrajectoryWarning) { + this._logQuotaTrajectoryNudgeClosed(this._activeTrajectoryWarning); + } this._storeTrajectoryShown(); } this._activeQuotaNotificationKind = QuotaNotificationKind.None; @@ -324,13 +323,13 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private async _handleCreditEfficiencyLearnMoreCommand(accessor: ServicesAccessor): Promise { if (this._activeQuotaNotificationKind === QuotaNotificationKind.Trajectory && this._activeTrajectoryWarning) { - this._handleQuotaTrajectoryNudgeAction(this._activeTrajectoryWarning, 'learnMore'); + this._handleQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryWarning); } await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); } - private _handleQuotaTrajectoryNudgeAction(warning: { averageDailyUsage: number; percentUsed: number }, action: ChatQuotaTrajectoryNudgeAction): void { - this._logQuotaTrajectoryNudgeActionClicked(warning, action); + private _handleQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { + this._logQuotaTrajectoryNudgeLinkClicked(warning); this._storeTrajectoryShown(); queueMicrotask(() => this._hideNotification()); } @@ -345,11 +344,12 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } - private _logQuotaTrajectoryNudgeActionClicked(warning: { averageDailyUsage: number; percentUsed: number }, action: ChatQuotaTrajectoryNudgeAction): void { - this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeActionClicked', { - ...this._getQuotaTrajectoryNudgeTelemetryData(warning), - action, - }); + private _logQuotaTrajectoryNudgeClosed(warning: { averageDailyUsage: number; percentUsed: number }): void { + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeClosed', this._getQuotaTrajectoryNudgeTelemetryData(warning)); + } + + private _logQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } private _getQuotaTrajectoryNudgeTelemetryData(warning: { averageDailyUsage: number; percentUsed: number }): ChatQuotaTrajectoryNudgeEvent { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 56d864443d9099..7e8d2bbf7154e8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -708,7 +708,43 @@ suite('ChatQuotaNotificationContribution', () => { }]); }); - test('logs action click telemetry', async () => { + test('logs close telemetry', async () => { + const telemetryService = new TestTelemetryService(); + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled', telemetryService }); + + await flushPromises(); + notificationMock.dismiss(); + + assert.deepStrictEqual(telemetryService.events, [ + { + name: 'chatQuotaTrajectoryNudgeShown', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 4.67, + percentUsed: 28, + }, + }, + { + name: 'chatQuotaTrajectoryNudgeClosed', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 4.67, + percentUsed: 28, + }, + }, + ]); + }); + + test('logs link click telemetry', async () => { const telemetryService = new TestTelemetryService(); createContribution({ entitlement: ChatEntitlement.Pro, @@ -737,13 +773,12 @@ suite('ChatQuotaNotificationContribution', () => { }, }, { - name: 'chatQuotaTrajectoryNudgeActionClicked', + name: 'chatQuotaTrajectoryNudgeLinkClicked', data: { severity: 'info', entitlement: 'Pro', averageDailyUsage: 4.67, percentUsed: 28, - action: 'learnMore', }, }, ], From 04b5c2b4c867245ff6b2ef56e1f79b0d2e2b4215 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 17 Jun 2026 15:20:23 -0700 Subject: [PATCH 09/24] Include Max in quota trajectory nudge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 2 +- .../browser/chatQuotaNotification.test.ts | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 7cea42b7b0528f..27c6862c94a7bb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -557,7 +557,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _isTrajectoryEligibleEntitlement(): boolean { const entitlement = this._chatEntitlementService.entitlement; - return entitlement === ChatEntitlement.Pro || entitlement === ChatEntitlement.ProPlus; + return entitlement === ChatEntitlement.Pro || entitlement === ChatEntitlement.ProPlus || entitlement === ChatEntitlement.Max; } private _isManagedPlanBlocked(): boolean { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 7e8d2bbf7154e8..cba9f1e136c0ba 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -653,6 +653,21 @@ suite('ChatQuotaNotificationContribution', () => { assert.ok(notificationMock.getNotification()); }); + test('shows for Max users', async () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Max, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + + assert.ok(notificationMock.getNotification()); + }); + test('does not show when projected daily usage is below threshold', async () => { const { notificationMock } = createContribution({ entitlement: ChatEntitlement.Pro, @@ -829,8 +844,8 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('does not show for Edu, Max, Business, Enterprise, Free, or Unknown users', async () => { - for (const entitlement of [ChatEntitlement.EDU, ChatEntitlement.Max, ChatEntitlement.Business, ChatEntitlement.Enterprise, ChatEntitlement.Free, ChatEntitlement.Unknown]) { + test('does not show for Edu, Business, Enterprise, Free, or Unknown users', async () => { + for (const entitlement of [ChatEntitlement.EDU, ChatEntitlement.Business, ChatEntitlement.Enterprise, ChatEntitlement.Free, ChatEntitlement.Unknown]) { const { notificationMock } = createContribution({ entitlement, quotas: { From e24e04b14a037c443c9e7b004243e25dcb1f3aba Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 17 Jun 2026 16:49:42 -0700 Subject: [PATCH 10/24] Address quota nudge review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 47 +++++++--- .../browser/chatQuotaNotification.test.ts | 86 ++++++++++++++----- 2 files changed, 101 insertions(+), 32 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 27c6862c94a7bb..a8df6367e23633 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -26,6 +26,8 @@ const THRESHOLDS = [50, 75, 90, 95]; const TRAJECTORY_DAILY_USAGE_THRESHOLD = 4.5; const TRAJECTORY_MINIMUM_PERCENT_USED = 10; const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; +const BILLING_PERIOD_DAYS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; const TRAJECTORY_TREATMENT = 'chatQuotaTrajectoryNudge'; const TRAJECTORY_SHOWN_STORAGE_KEY = 'chat.quotaTrajectory.shownPeriod'; const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; @@ -81,6 +83,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _prevSessionPercentUsed: number | undefined; private _prevWeeklyPercentUsed: number | undefined; private _trajectoryNudgeEnabled = false; + private _trajectoryTreatmentInitialized = false; private _activeQuotaNotificationKind = QuotaNotificationKind.None; private _lastLoggedTrajectoryShownSignature: string | undefined; private _activeTrajectoryWarning: { averageDailyUsage: number; percentUsed: number } | undefined; @@ -99,7 +102,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._register(this._chatEntitlementService.onDidChangeQuotaRemaining(() => this._update())); this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => this._update())); this._register(this._chatEntitlementService.onDidChangeEntitlement(() => this._update())); - this._register(this._assignmentService.onDidRefetchAssignments(() => this._updateTrajectoryTreatment())); + this._register(this._assignmentService.onDidRefetchAssignments(() => { void this._updateTrajectoryTreatment(); })); this._register(this._chatInputNotificationService.onDidDismiss(id => { if (id !== QUOTA_NOTIFICATION_ID) { return; @@ -125,15 +128,16 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } })); - // Check initial state in case quota is already exhausted at startup - this._updateTrajectoryTreatment(); + void this._updateTrajectoryTreatment(); this._update(); } private async _updateTrajectoryTreatment(): Promise { const trajectoryTreatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); const trajectoryEnabled = trajectoryTreatment === 'enabled'; - if (this._trajectoryNudgeEnabled === trajectoryEnabled) { + const wasInitialized = this._trajectoryTreatmentInitialized; + this._trajectoryTreatmentInitialized = true; + if (wasInitialized && this._trajectoryNudgeEnabled === trajectoryEnabled) { return; } this._trajectoryNudgeEnabled = trajectoryEnabled; @@ -213,6 +217,17 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return; } + // Nothing new to show — only hide if the exhausted notification is + // active and the quota is no longer exhausted (state-driven). + if (this._showingExhausted && !this._isQuotaUsedUp()) { + this._hideNotification(); + } + + if (!this._trajectoryTreatmentInitialized) { + this._captureNotificationBaselines(); + return; + } + // Priority 2: Quota approaching threshold if (isQuotaNotificationEligible) { const trajectoryWarning = this._computeQuotaTrajectoryWarning(); @@ -235,15 +250,25 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return; } - // Nothing new to show — only hide if the exhausted notification is - // active and the quota is no longer exhausted (state-driven). - if (this._showingExhausted && !this._isQuotaUsedUp()) { - this._hideNotification(); - } + this._captureNotificationBaselines(); } // --- Threshold crossing detection ---------------------------------------- + private _captureNotificationBaselines(): void { + const snapshot = this._getRelevantSnapshot(); + this._prevQuotaPercentUsed = !snapshot || snapshot.unlimited ? undefined : 100 - snapshot.percentRemaining; + this._prevSessionPercentUsed = this._getRateLimitPercentUsed(this._chatEntitlementService.quotas.sessionRateLimit); + this._prevWeeklyPercentUsed = this._getRateLimitPercentUsed(this._chatEntitlementService.quotas.weeklyRateLimit); + } + + private _getRateLimitPercentUsed(snapshot: IRateLimitSnapshot | undefined): number | undefined { + if (!snapshot || snapshot.unlimited) { + return undefined; + } + return 100 - snapshot.percentRemaining; + } + private _computeQuotaWarning(): { percentUsed: number } | undefined { const snapshot = this._getRelevantSnapshot(); if (!snapshot || snapshot.unlimited) { @@ -280,8 +305,8 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } - const periodStartTime = resetTime - (30 * 24 * 60 * 60 * 1000); - const elapsedDays = Math.max(0, (Date.now() - periodStartTime) / (24 * 60 * 60 * 1000)); + const periodStartTime = resetTime - (BILLING_PERIOD_DAYS * MS_PER_DAY); + const elapsedDays = Math.max(0, (Date.now() - periodStartTime) / MS_PER_DAY); if (elapsedDays <= 0) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index cba9f1e136c0ba..9da62842d4bfbf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -116,13 +116,15 @@ function createMockNotificationService() { setCount++; onDidChange.fire(); }, - deleteNotification(_id: string) { - deleted = true; - dismissed = false; - onDidChange.fire(); + deleteNotification(id: string) { + if (lastNotification?.id === id && !deleted) { + deleted = true; + dismissed = false; + onDidChange.fire(); + } }, dismissNotification(id: string) { - if (!lastNotification || lastNotification.id !== id || dismissed) { + if (!lastNotification || lastNotification.id !== id || deleted || dismissed) { return; } dismissed = true; @@ -152,7 +154,7 @@ function createMockNotificationService() { }; } -function createMockAssignmentService(treatments?: Readonly>) { +function createMockAssignmentService(treatments?: Readonly>>) { const onDidRefetchAssignments = new Emitter(); const service: IWorkbenchAssignmentService = { _serviceBrand: undefined, @@ -238,7 +240,7 @@ suite('ChatQuotaNotificationContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string; trajectoryTreatment?: string; telemetryService?: ITelemetryService }) { + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string; trajectoryTreatment?: string | Promise; telemetryService?: ITelemetryService }) { const entitlementMock = createMockEntitlementService(entitlementOpts); const notificationMock = createMockNotificationService(); const assignmentMock = createMockAssignmentService({ @@ -447,31 +449,35 @@ suite('ChatQuotaNotificationContribution', () => { // --- Quota approaching threshold ---------------------------------------- suite('quota approaching threshold', () => { - test('first data arrival stores baseline without notification', () => { + test('first data arrival stores baseline without notification', async () => { const { notificationMock } = createContribution({ quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(25) }, // 75% used }); - // Initial _update runs in constructor but 75% is baseline, no crossing + await flushPromises(); + + // Initial treatment resolution stores 75% as the baseline without notifying. assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('notifies when crossing 50% threshold', () => { + test('notifies when crossing 50% threshold', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, // 40% used baseline }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); // 50% used assert.ok(notificationMock.getNotification()); assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 50%'); }); - test('does not re-show the same threshold', () => { + test('does not re-show the same threshold', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); assert.ok(notificationMock.getNotification()); @@ -482,11 +488,12 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('shows higher threshold when usage increases', () => { + test('shows higher threshold when usage increases', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); // 50% assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 50%'); @@ -507,12 +514,13 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('does not show approaching notification for PRU user', () => { + test('does not show approaching notification for PRU user', async () => { const { entitlementMock, notificationMock } = createContribution({ entitlement: ChatEntitlement.Pro, quotas: { usageBasedBilling: false, premiumChat: makeQuotaSnapshot(60) }, }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(5) }); assert.strictEqual(notificationMock.getNotification(), undefined); }); @@ -698,6 +706,34 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); + test('does not show lower priority warnings before treatment resolves', async () => { + let resolveTreatment: ((value: string | undefined) => void) | undefined; + const trajectoryTreatment = new Promise(resolve => { + resolveTreatment = resolve; + }); + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + sessionRateLimit: makeRateLimitSnapshot(60), + }, + }, { trajectoryTreatment }); + + updateQuotas(entitlementMock, { sessionRateLimit: makeRateLimitSnapshot(25) }); + assert.strictEqual(notificationMock.getNotification(), undefined); + + assert.ok(resolveTreatment); + resolveTreatment('enabled'); + await flushPromises(); + + const notification = notificationMock.getNotification(); + assert.ok(notification); + const message = notification.message; + assert.ok(typeof message !== 'string' && message.value.includes('monthly allowance')); + }); + test('logs shown telemetry once per quota period', async () => { const telemetryService = new TestTelemetryService(); const { entitlementMock } = createContribution({ @@ -866,11 +902,12 @@ suite('ChatQuotaNotificationContribution', () => { // --- Rate-limit warnings ------------------------------------------------ suite('rate-limit warnings', () => { - test('shows session rate limit warning on threshold crossing', () => { + test('shows session rate limit warning on threshold crossing', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, sessionRateLimit: makeRateLimitSnapshot(60) }, // baseline }); + await flushPromises(); updateQuotas(entitlementMock, { sessionRateLimit: makeRateLimitSnapshot(25) }); // 75% used assert.ok(notificationMock.getNotification()); @@ -878,11 +915,12 @@ suite('ChatQuotaNotificationContribution', () => { assert.ok((notificationMock.getNotification()!.message as string).includes('session')); }); - test('shows weekly rate limit warning on threshold crossing', () => { + test('shows weekly rate limit warning on threshold crossing', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, weeklyRateLimit: makeRateLimitSnapshot(60) }, // baseline }); + await flushPromises(); updateQuotas(entitlementMock, { weeklyRateLimit: makeRateLimitSnapshot(10) }); // 90% used assert.ok(notificationMock.getNotification()); @@ -890,11 +928,12 @@ suite('ChatQuotaNotificationContribution', () => { assert.ok((notificationMock.getNotification()!.message as string).includes('weekly')); }); - test('first rate limit data stores baseline without notification', () => { + test('first rate limit data stores baseline without notification', async () => { const { notificationMock } = createContribution({ quotas: { usageBasedBilling: true, sessionRateLimit: makeRateLimitSnapshot(10) }, // 90% used }); + await flushPromises(); assert.strictEqual(notificationMock.getNotification(), undefined); }); }); @@ -911,7 +950,7 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); }); - test('approaching threshold takes priority over rate limit', () => { + test('approaching threshold takes priority over rate limit', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, @@ -920,6 +959,7 @@ suite('ChatQuotaNotificationContribution', () => { }, }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(10), // 90% — crosses threshold sessionRateLimit: makeRateLimitSnapshot(25), // 75% — crosses threshold @@ -933,46 +973,50 @@ suite('ChatQuotaNotificationContribution', () => { // --- Approaching notification descriptions ------------------------------ suite('approaching notification descriptions', () => { - test('free user gets upgrade action', () => { + test('free user gets upgrade action', async () => { const { entitlementMock, notificationMock } = createContribution({ entitlement: ChatEntitlement.Free, quotas: { usageBasedBilling: true, chat: makeQuotaSnapshot(60) }, }); + await flushPromises(); updateQuotas(entitlementMock, { chat: makeQuotaSnapshot(50) }); assert.ok(notificationMock.getNotification()); assert.strictEqual(notificationMock.getNotification()!.description, 'Upgrade to continue past the limit.'); }); - test('managed plan user gets admin message', () => { + test('managed plan user gets admin message', async () => { const { entitlementMock, notificationMock } = createContribution({ entitlement: ChatEntitlement.Enterprise, quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); assert.ok(notificationMock.getNotification()); assert.strictEqual(notificationMock.getNotification()!.description, 'Contact your admin to increase your limits.'); }); - test('paid user with overages enabled gets budget message', () => { + test('paid user with overages enabled gets budget message', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60), additionalUsageEnabled: true }, }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); assert.ok(notificationMock.getNotification()); assert.strictEqual(notificationMock.getNotification()!.description, 'Additional budget is enabled to cover extra usage.'); }); - test('paid user without overages gets set budget action', () => { + test('paid user without overages gets set budget action', async () => { const { entitlementMock, notificationMock } = createContribution({ quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, }); + await flushPromises(); updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); assert.ok(notificationMock.getNotification()); From 148da646417eb929e4d00d86ab3c6b2ef2654df1 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 17 Jun 2026 17:17:25 -0700 Subject: [PATCH 11/24] Integrate quota trajectory notification levels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 130 ++++++++++++------ 1 file changed, 85 insertions(+), 45 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index a8df6367e23633..e01af4337ed91e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -22,10 +22,6 @@ import { ILanguageModelsService } from '../common/languageModels.js'; import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from './widget/input/chatInputNotificationService.js'; const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; -const THRESHOLDS = [50, 75, 90, 95]; -const TRAJECTORY_DAILY_USAGE_THRESHOLD = 4.5; -const TRAJECTORY_MINIMUM_PERCENT_USED = 10; -const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; const BILLING_PERIOD_DAYS = 30; const MS_PER_DAY = 24 * 60 * 60 * 1000; const TRAJECTORY_TREATMENT = 'chatQuotaTrajectoryNudge'; @@ -33,11 +29,48 @@ const TRAJECTORY_SHOWN_STORAGE_KEY = 'chat.quotaTrajectory.shownPeriod'; const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; +type QuotaTrajectoryNotificationLevel = { + readonly kind: 'trajectory'; + readonly dailyUsageThreshold: number; + readonly minimumPercentUsed: number; + readonly maximumPercentUsed: number; +}; + +type QuotaApproachingNotificationLevel = { + readonly kind: 'approaching'; + readonly percentUsed: number; +}; + +type QuotaNotificationLevel = QuotaTrajectoryNotificationLevel | QuotaApproachingNotificationLevel; + +const QUOTA_NOTIFICATION_LEVELS: readonly QuotaNotificationLevel[] = [ + { kind: 'trajectory', dailyUsageThreshold: 4.5, minimumPercentUsed: 10, maximumPercentUsed: 35 }, + { kind: 'approaching', percentUsed: 95 }, + { kind: 'approaching', percentUsed: 90 }, + { kind: 'approaching', percentUsed: 75 }, + { kind: 'approaching', percentUsed: 50 }, +]; + const enum QuotaNotificationKind { None, Trajectory, } +type QuotaApproachingWarning = { + readonly kind: 'approaching'; + readonly level: QuotaApproachingNotificationLevel; + readonly percentUsed: number; +}; + +type QuotaTrajectoryWarning = { + readonly kind: 'trajectory'; + readonly level: QuotaTrajectoryNotificationLevel; + readonly averageDailyUsage: number; + readonly percentUsed: number; +}; + +type QuotaWarning = QuotaApproachingWarning | QuotaTrajectoryWarning; + type ChatQuotaTrajectoryNudgeEvent = { severity: 'info'; entitlement: string; @@ -56,7 +89,7 @@ type ChatQuotaTrajectoryNudgeClassification = { /** * Core-side workbench contribution that shows chat input notifications for - * quota exhaustion and quota-approaching thresholds. + * quota exhaustion and quota-related warning levels. * * Listens to `IChatEntitlementService` quota change events and determines * whether a new threshold has been crossed, then shows the highest-priority @@ -86,7 +119,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _trajectoryTreatmentInitialized = false; private _activeQuotaNotificationKind = QuotaNotificationKind.None; private _lastLoggedTrajectoryShownSignature: string | undefined; - private _activeTrajectoryWarning: { averageDailyUsage: number; percentUsed: number } | undefined; + private _activeTrajectoryWarning: QuotaTrajectoryWarning | undefined; constructor( @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @@ -228,17 +261,11 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return; } - // Priority 2: Quota approaching threshold + // Priority 2: Quota warning levels if (isQuotaNotificationEligible) { - const trajectoryWarning = this._computeQuotaTrajectoryWarning(); - if (trajectoryWarning) { - this._showQuotaTrajectoryWarning(trajectoryWarning); - return; - } - const quotaWarning = this._computeQuotaWarning(); if (quotaWarning) { - this._showQuotaApproachingWarning(quotaWarning); + this._showQuotaWarning(quotaWarning); return; } } @@ -253,7 +280,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._captureNotificationBaselines(); } - // --- Threshold crossing detection ---------------------------------------- + // --- Quota warning levels ----------------------------------------------- private _captureNotificationBaselines(): void { const snapshot = this._getRelevantSnapshot(); @@ -269,7 +296,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return 100 - snapshot.percentRemaining; } - private _computeQuotaWarning(): { percentUsed: number } | undefined { + private _computeQuotaWarning(): QuotaWarning | undefined { const snapshot = this._getRelevantSnapshot(); if (!snapshot || snapshot.unlimited) { this._prevQuotaPercentUsed = undefined; @@ -277,24 +304,30 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } const percentUsed = 100 - snapshot.percentRemaining; - const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); + const warning = this._findQuotaWarning(percentUsed); this._prevQuotaPercentUsed = percentUsed; - if (crossed !== undefined) { - return { percentUsed: Math.floor(percentUsed) }; + return warning; + } + + private _findQuotaWarning(percentUsed: number): QuotaWarning | undefined { + for (const level of QUOTA_NOTIFICATION_LEVELS) { + if (level.kind === 'trajectory') { + const warning = this._computeQuotaTrajectoryWarning(level, percentUsed); + if (warning) { + return warning; + } + } else if (this._hasCrossedThresholdLevel(level, percentUsed, this._prevQuotaPercentUsed)) { + return { kind: 'approaching', level, percentUsed: Math.floor(percentUsed) }; + } } return undefined; } - private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined { + private _computeQuotaTrajectoryWarning(level: QuotaTrajectoryNotificationLevel, percentUsed: number): QuotaTrajectoryWarning | undefined { if (!this._trajectoryNudgeEnabled || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryShownInCurrentPeriod()) { return undefined; } - const snapshot = this._getRelevantSnapshot(); - if (!snapshot || snapshot.unlimited || snapshot.percentRemaining <= 0) { - return undefined; - } - const resetDate = this._chatEntitlementService.quotas.resetDate; if (!resetDate) { return undefined; @@ -311,19 +344,26 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } - const percentUsed = 100 - snapshot.percentRemaining; - if (percentUsed < TRAJECTORY_MINIMUM_PERCENT_USED || percentUsed > TRAJECTORY_MAXIMUM_PERCENT_USED) { + if (percentUsed <= 0 || percentUsed < level.minimumPercentUsed || percentUsed > level.maximumPercentUsed) { return undefined; } const averageDailyUsage = percentUsed / elapsedDays; - if (averageDailyUsage >= TRAJECTORY_DAILY_USAGE_THRESHOLD) { - return { averageDailyUsage, percentUsed }; + if (averageDailyUsage >= level.dailyUsageThreshold) { + return { kind: 'trajectory', level, averageDailyUsage, percentUsed }; } return undefined; } - private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { + private _showQuotaWarning(warning: QuotaWarning): void { + if (warning.kind === 'trajectory') { + this._showQuotaTrajectoryWarning(warning); + } else { + this._showQuotaApproachingWarning(warning); + } + } + + private _showQuotaTrajectoryWarning(warning: QuotaTrajectoryWarning): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.Trajectory; this._activeTrajectoryWarning = warning; @@ -353,13 +393,13 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); } - private _handleQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { + private _handleQuotaTrajectoryNudgeLinkClicked(warning: QuotaTrajectoryWarning): void { this._logQuotaTrajectoryNudgeLinkClicked(warning); this._storeTrajectoryShown(); queueMicrotask(() => this._hideNotification()); } - private _logQuotaTrajectoryNudgeShown(warning: { averageDailyUsage: number; percentUsed: number }): void { + private _logQuotaTrajectoryNudgeShown(warning: QuotaTrajectoryWarning): void { const resetPeriod = this._getTrajectoryPeriodKey(); const signature = resetPeriod ?? 'unknown'; if (signature === this._lastLoggedTrajectoryShownSignature) { @@ -369,15 +409,15 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } - private _logQuotaTrajectoryNudgeClosed(warning: { averageDailyUsage: number; percentUsed: number }): void { + private _logQuotaTrajectoryNudgeClosed(warning: QuotaTrajectoryWarning): void { this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeClosed', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } - private _logQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { + private _logQuotaTrajectoryNudgeLinkClicked(warning: QuotaTrajectoryWarning): void { this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } - private _getQuotaTrajectoryNudgeTelemetryData(warning: { averageDailyUsage: number; percentUsed: number }): ChatQuotaTrajectoryNudgeEvent { + private _getQuotaTrajectoryNudgeTelemetryData(warning: QuotaTrajectoryWarning): ChatQuotaTrajectoryNudgeEvent { return { severity: 'info', entitlement: ChatEntitlement[this._chatEntitlementService.entitlement], @@ -389,19 +429,19 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo /** * Returns the highest threshold that was newly crossed, or `undefined`. */ - private _findCrossedThreshold(current: number, previous: number | undefined): number | undefined { - if (previous === undefined) { - return undefined; - } - for (let i = THRESHOLDS.length - 1; i >= 0; i--) { - const threshold = THRESHOLDS[i]; - if (previous < threshold && current >= threshold) { - return threshold; + private _findCrossedThresholdLevel(current: number, previous: number | undefined): QuotaApproachingNotificationLevel | undefined { + for (const level of QUOTA_NOTIFICATION_LEVELS) { + if (level.kind === 'approaching' && this._hasCrossedThresholdLevel(level, current, previous)) { + return level; } } return undefined; } + private _hasCrossedThresholdLevel(level: QuotaApproachingNotificationLevel, current: number, previous: number | undefined): boolean { + return previous !== undefined && previous < level.percentUsed && current >= level.percentUsed; + } + // --- Quota exhausted --------------------------------------------------- private _showExhaustedNotification(): void { @@ -466,7 +506,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo // --- Quota approaching -------------------------------------------------- - private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { + private _showQuotaApproachingWarning(warning: QuotaApproachingWarning): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; @@ -531,7 +571,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return { newPrev: undefined }; } const percentUsed = 100 - snapshot.percentRemaining; - const crossed = this._findCrossedThreshold(percentUsed, prevPercentUsed); + const crossed = this._findCrossedThresholdLevel(percentUsed, prevPercentUsed); return { newPrev: percentUsed, warning: crossed !== undefined From d08dc2e130587402543aa79292ccc305d6e97742 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 07:57:13 -0700 Subject: [PATCH 12/24] Gate quota nudge experiment by locale Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 11 +++++- .../browser/chatQuotaNotification.test.ts | 38 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index e01af4337ed91e..375f8d731876df 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -6,6 +6,7 @@ import { safeIntl } from '../../../../base/common/date.js'; import { createMarkdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Language } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; @@ -166,8 +167,16 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } private async _updateTrajectoryTreatment(): Promise { + if (!Language.isDefaultVariant()) { + this._setTrajectoryTreatment(false); + return; + } + const trajectoryTreatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); - const trajectoryEnabled = trajectoryTreatment === 'enabled'; + this._setTrajectoryTreatment(trajectoryTreatment === 'enabled'); + } + + private _setTrajectoryTreatment(trajectoryEnabled: boolean): void { const wasInitialized = this._trajectoryTreatmentInitialized; this._trajectoryTreatmentInitialized = true; if (wasInitialized && this._trajectoryNudgeEnabled === trajectoryEnabled) { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 9da62842d4bfbf..d26627169f85c7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as sinon from 'sinon'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { createMarkdownCommandLink } from '../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { Language } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; @@ -156,15 +158,19 @@ function createMockNotificationService() { function createMockAssignmentService(treatments?: Readonly>>) { const onDidRefetchAssignments = new Emitter(); + const getTreatmentCalls: string[] = []; const service: IWorkbenchAssignmentService = { _serviceBrand: undefined, onDidRefetchAssignments: onDidRefetchAssignments.event, - getTreatment(name: string) { return Promise.resolve(treatments?.[name]); }, + getTreatment(name: string) { + getTreatmentCalls.push(name); + return Promise.resolve(treatments?.[name]); + }, getCurrentExperiments() { return Promise.resolve(undefined); }, addTelemetryAssignmentFilter() { }, } as unknown as IWorkbenchAssignmentService; - return { service, onDidRefetchAssignments }; + return { service, onDidRefetchAssignments, getTreatmentCalls }; } class TestTelemetryService extends NullTelemetryServiceShape { @@ -240,6 +246,10 @@ suite('ChatQuotaNotificationContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => { + sinon.restore(); + }); + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string; trajectoryTreatment?: string | Promise; telemetryService?: ITelemetryService }) { const entitlementMock = createMockEntitlementService(entitlementOpts); const notificationMock = createMockNotificationService(); @@ -593,6 +603,30 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); + test('does not enroll when UI language is not translated', async () => { + sinon.stub(Language, 'isDefaultVariant').returns(false); + const { assignmentMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'enabled' }); + + await flushPromises(); + assignmentMock.onDidRefetchAssignments.fire(); + await flushPromises(); + + assert.deepStrictEqual({ + treatments: assignmentMock.getTreatmentCalls, + notification: notificationMock.getNotification(), + }, { + treatments: [], + notification: undefined, + }); + }); + test('does not show outside monthly usage window', async () => { const results = []; for (const percentRemaining of [91, 64]) { From a89402c47c5cba3eb9ac6b87b4c91d5f887ef715 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 09:09:10 -0700 Subject: [PATCH 13/24] Simplify quota nudge locale guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 130 ++++++------------ 1 file changed, 45 insertions(+), 85 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 375f8d731876df..486805b2bfabb3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -23,6 +23,10 @@ import { ILanguageModelsService } from '../common/languageModels.js'; import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from './widget/input/chatInputNotificationService.js'; const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; +const THRESHOLDS = [50, 75, 90, 95]; +const TRAJECTORY_DAILY_USAGE_THRESHOLD = 4.5; +const TRAJECTORY_MINIMUM_PERCENT_USED = 10; +const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; const BILLING_PERIOD_DAYS = 30; const MS_PER_DAY = 24 * 60 * 60 * 1000; const TRAJECTORY_TREATMENT = 'chatQuotaTrajectoryNudge'; @@ -30,48 +34,11 @@ const TRAJECTORY_SHOWN_STORAGE_KEY = 'chat.quotaTrajectory.shownPeriod'; const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; -type QuotaTrajectoryNotificationLevel = { - readonly kind: 'trajectory'; - readonly dailyUsageThreshold: number; - readonly minimumPercentUsed: number; - readonly maximumPercentUsed: number; -}; - -type QuotaApproachingNotificationLevel = { - readonly kind: 'approaching'; - readonly percentUsed: number; -}; - -type QuotaNotificationLevel = QuotaTrajectoryNotificationLevel | QuotaApproachingNotificationLevel; - -const QUOTA_NOTIFICATION_LEVELS: readonly QuotaNotificationLevel[] = [ - { kind: 'trajectory', dailyUsageThreshold: 4.5, minimumPercentUsed: 10, maximumPercentUsed: 35 }, - { kind: 'approaching', percentUsed: 95 }, - { kind: 'approaching', percentUsed: 90 }, - { kind: 'approaching', percentUsed: 75 }, - { kind: 'approaching', percentUsed: 50 }, -]; - const enum QuotaNotificationKind { None, Trajectory, } -type QuotaApproachingWarning = { - readonly kind: 'approaching'; - readonly level: QuotaApproachingNotificationLevel; - readonly percentUsed: number; -}; - -type QuotaTrajectoryWarning = { - readonly kind: 'trajectory'; - readonly level: QuotaTrajectoryNotificationLevel; - readonly averageDailyUsage: number; - readonly percentUsed: number; -}; - -type QuotaWarning = QuotaApproachingWarning | QuotaTrajectoryWarning; - type ChatQuotaTrajectoryNudgeEvent = { severity: 'info'; entitlement: string; @@ -90,7 +57,7 @@ type ChatQuotaTrajectoryNudgeClassification = { /** * Core-side workbench contribution that shows chat input notifications for - * quota exhaustion and quota-related warning levels. + * quota exhaustion and quota-approaching thresholds. * * Listens to `IChatEntitlementService` quota change events and determines * whether a new threshold has been crossed, then shows the highest-priority @@ -120,7 +87,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _trajectoryTreatmentInitialized = false; private _activeQuotaNotificationKind = QuotaNotificationKind.None; private _lastLoggedTrajectoryShownSignature: string | undefined; - private _activeTrajectoryWarning: QuotaTrajectoryWarning | undefined; + private _activeTrajectoryWarning: { averageDailyUsage: number; percentUsed: number } | undefined; constructor( @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @@ -270,11 +237,17 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return; } - // Priority 2: Quota warning levels + // Priority 2: Quota approaching threshold if (isQuotaNotificationEligible) { + const trajectoryWarning = this._computeQuotaTrajectoryWarning(); + if (trajectoryWarning) { + this._showQuotaTrajectoryWarning(trajectoryWarning); + return; + } + const quotaWarning = this._computeQuotaWarning(); if (quotaWarning) { - this._showQuotaWarning(quotaWarning); + this._showQuotaApproachingWarning(quotaWarning); return; } } @@ -289,7 +262,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._captureNotificationBaselines(); } - // --- Quota warning levels ----------------------------------------------- + // --- Threshold crossing detection ---------------------------------------- private _captureNotificationBaselines(): void { const snapshot = this._getRelevantSnapshot(); @@ -305,7 +278,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return 100 - snapshot.percentRemaining; } - private _computeQuotaWarning(): QuotaWarning | undefined { + private _computeQuotaWarning(): { percentUsed: number } | undefined { const snapshot = this._getRelevantSnapshot(); if (!snapshot || snapshot.unlimited) { this._prevQuotaPercentUsed = undefined; @@ -313,30 +286,24 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } const percentUsed = 100 - snapshot.percentRemaining; - const warning = this._findQuotaWarning(percentUsed); + const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); this._prevQuotaPercentUsed = percentUsed; - return warning; - } - - private _findQuotaWarning(percentUsed: number): QuotaWarning | undefined { - for (const level of QUOTA_NOTIFICATION_LEVELS) { - if (level.kind === 'trajectory') { - const warning = this._computeQuotaTrajectoryWarning(level, percentUsed); - if (warning) { - return warning; - } - } else if (this._hasCrossedThresholdLevel(level, percentUsed, this._prevQuotaPercentUsed)) { - return { kind: 'approaching', level, percentUsed: Math.floor(percentUsed) }; - } + if (crossed !== undefined) { + return { percentUsed: Math.floor(percentUsed) }; } return undefined; } - private _computeQuotaTrajectoryWarning(level: QuotaTrajectoryNotificationLevel, percentUsed: number): QuotaTrajectoryWarning | undefined { + private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined { if (!this._trajectoryNudgeEnabled || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryShownInCurrentPeriod()) { return undefined; } + const snapshot = this._getRelevantSnapshot(); + if (!snapshot || snapshot.unlimited || snapshot.percentRemaining <= 0) { + return undefined; + } + const resetDate = this._chatEntitlementService.quotas.resetDate; if (!resetDate) { return undefined; @@ -353,26 +320,19 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } - if (percentUsed <= 0 || percentUsed < level.minimumPercentUsed || percentUsed > level.maximumPercentUsed) { + const percentUsed = 100 - snapshot.percentRemaining; + if (percentUsed < TRAJECTORY_MINIMUM_PERCENT_USED || percentUsed > TRAJECTORY_MAXIMUM_PERCENT_USED) { return undefined; } const averageDailyUsage = percentUsed / elapsedDays; - if (averageDailyUsage >= level.dailyUsageThreshold) { - return { kind: 'trajectory', level, averageDailyUsage, percentUsed }; + if (averageDailyUsage >= TRAJECTORY_DAILY_USAGE_THRESHOLD) { + return { averageDailyUsage, percentUsed }; } return undefined; } - private _showQuotaWarning(warning: QuotaWarning): void { - if (warning.kind === 'trajectory') { - this._showQuotaTrajectoryWarning(warning); - } else { - this._showQuotaApproachingWarning(warning); - } - } - - private _showQuotaTrajectoryWarning(warning: QuotaTrajectoryWarning): void { + private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.Trajectory; this._activeTrajectoryWarning = warning; @@ -402,13 +362,13 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); } - private _handleQuotaTrajectoryNudgeLinkClicked(warning: QuotaTrajectoryWarning): void { + private _handleQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { this._logQuotaTrajectoryNudgeLinkClicked(warning); this._storeTrajectoryShown(); queueMicrotask(() => this._hideNotification()); } - private _logQuotaTrajectoryNudgeShown(warning: QuotaTrajectoryWarning): void { + private _logQuotaTrajectoryNudgeShown(warning: { averageDailyUsage: number; percentUsed: number }): void { const resetPeriod = this._getTrajectoryPeriodKey(); const signature = resetPeriod ?? 'unknown'; if (signature === this._lastLoggedTrajectoryShownSignature) { @@ -418,15 +378,15 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } - private _logQuotaTrajectoryNudgeClosed(warning: QuotaTrajectoryWarning): void { + private _logQuotaTrajectoryNudgeClosed(warning: { averageDailyUsage: number; percentUsed: number }): void { this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeClosed', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } - private _logQuotaTrajectoryNudgeLinkClicked(warning: QuotaTrajectoryWarning): void { + private _logQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', this._getQuotaTrajectoryNudgeTelemetryData(warning)); } - private _getQuotaTrajectoryNudgeTelemetryData(warning: QuotaTrajectoryWarning): ChatQuotaTrajectoryNudgeEvent { + private _getQuotaTrajectoryNudgeTelemetryData(warning: { averageDailyUsage: number; percentUsed: number }): ChatQuotaTrajectoryNudgeEvent { return { severity: 'info', entitlement: ChatEntitlement[this._chatEntitlementService.entitlement], @@ -438,19 +398,19 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo /** * Returns the highest threshold that was newly crossed, or `undefined`. */ - private _findCrossedThresholdLevel(current: number, previous: number | undefined): QuotaApproachingNotificationLevel | undefined { - for (const level of QUOTA_NOTIFICATION_LEVELS) { - if (level.kind === 'approaching' && this._hasCrossedThresholdLevel(level, current, previous)) { - return level; + private _findCrossedThreshold(current: number, previous: number | undefined): number | undefined { + if (previous === undefined) { + return undefined; + } + for (let i = THRESHOLDS.length - 1; i >= 0; i--) { + const threshold = THRESHOLDS[i]; + if (previous < threshold && current >= threshold) { + return threshold; } } return undefined; } - private _hasCrossedThresholdLevel(level: QuotaApproachingNotificationLevel, current: number, previous: number | undefined): boolean { - return previous !== undefined && previous < level.percentUsed && current >= level.percentUsed; - } - // --- Quota exhausted --------------------------------------------------- private _showExhaustedNotification(): void { @@ -515,7 +475,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo // --- Quota approaching -------------------------------------------------- - private _showQuotaApproachingWarning(warning: QuotaApproachingWarning): void { + private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { this._showingExhausted = false; this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; @@ -580,7 +540,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return { newPrev: undefined }; } const percentUsed = 100 - snapshot.percentRemaining; - const crossed = this._findCrossedThresholdLevel(percentUsed, prevPercentUsed); + const crossed = this._findCrossedThreshold(percentUsed, prevPercentUsed); return { newPrev: percentUsed, warning: crossed !== undefined From 2afe123de992bfd12d4ca3f181765e1d73c8db48 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 09:24:26 -0700 Subject: [PATCH 14/24] Remove quota nudge notification kind Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 486805b2bfabb3..1c421f2b6367a0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -34,11 +34,6 @@ const TRAJECTORY_SHOWN_STORAGE_KEY = 'chat.quotaTrajectory.shownPeriod'; const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; -const enum QuotaNotificationKind { - None, - Trajectory, -} - type ChatQuotaTrajectoryNudgeEvent = { severity: 'info'; entitlement: string; @@ -85,7 +80,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _prevWeeklyPercentUsed: number | undefined; private _trajectoryNudgeEnabled = false; private _trajectoryTreatmentInitialized = false; - private _activeQuotaNotificationKind = QuotaNotificationKind.None; private _lastLoggedTrajectoryShownSignature: string | undefined; private _activeTrajectoryWarning: { averageDailyUsage: number; percentUsed: number } | undefined; @@ -108,13 +102,10 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo if (id !== QUOTA_NOTIFICATION_ID) { return; } - if (this._activeQuotaNotificationKind === QuotaNotificationKind.Trajectory) { - if (this._activeTrajectoryWarning) { - this._logQuotaTrajectoryNudgeClosed(this._activeTrajectoryWarning); - } + if (this._activeTrajectoryWarning) { + this._logQuotaTrajectoryNudgeClosed(this._activeTrajectoryWarning); this._storeTrajectoryShown(); } - this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; })); this._register(CommandsRegistry.registerCommand(CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, (accessor: ServicesAccessor) => this._handleCreditEfficiencyLearnMoreCommand(accessor))); @@ -334,7 +325,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { this._showingExhausted = false; - this._activeQuotaNotificationKind = QuotaNotificationKind.Trajectory; this._activeTrajectoryWarning = warning; this._logQuotaTrajectoryNudgeShown(warning); this._storeTrajectoryShown(); @@ -356,7 +346,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } private async _handleCreditEfficiencyLearnMoreCommand(accessor: ServicesAccessor): Promise { - if (this._activeQuotaNotificationKind === QuotaNotificationKind.Trajectory && this._activeTrajectoryWarning) { + if (this._activeTrajectoryWarning) { this._handleQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryWarning); } await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); @@ -415,7 +405,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showExhaustedNotification(): void { this._showingExhausted = true; - this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; const entitlement = this._chatEntitlementService.entitlement; @@ -458,7 +447,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showOverageActivationNotification(): void { this._showingExhausted = true; - this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; this._setNotification({ @@ -477,7 +465,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { this._showingExhausted = false; - this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; const entitlement = this._chatEntitlementService.entitlement; @@ -551,7 +538,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showRateLimitWarning(warning: { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined }): void { this._showingExhausted = false; - this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; const message = warning.type === 'session' @@ -601,7 +587,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showManagedPlanBlockedNotification(): void { this._showingExhausted = true; - this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; this._setNotification({ @@ -656,7 +641,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _hideNotification(): void { this._showingExhausted = false; - this._activeQuotaNotificationKind = QuotaNotificationKind.None; this._activeTrajectoryWarning = undefined; this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); } From 91fbb3fa835d15af518c62b68ae5ea2b6f2ea44d Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 09:44:54 -0700 Subject: [PATCH 15/24] Address quota nudge PR comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 44 +++++++++---------- .../input/chatInputNotificationService.ts | 2 - .../input/chatInputNotificationWidget.ts | 7 ++- .../media/chatInputNotificationWidget.css | 3 +- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 1c421f2b6367a0..1db695c61724b6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -81,7 +81,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _trajectoryNudgeEnabled = false; private _trajectoryTreatmentInitialized = false; private _lastLoggedTrajectoryShownSignature: string | undefined; - private _activeTrajectoryWarning: { averageDailyUsage: number; percentUsed: number } | undefined; + private _activeTrajectoryTelemetryData: ChatQuotaTrajectoryNudgeEvent | undefined; constructor( @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @@ -102,11 +102,11 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo if (id !== QUOTA_NOTIFICATION_ID) { return; } - if (this._activeTrajectoryWarning) { - this._logQuotaTrajectoryNudgeClosed(this._activeTrajectoryWarning); + if (this._activeTrajectoryTelemetryData) { + this._logQuotaTrajectoryNudgeClosed(this._activeTrajectoryTelemetryData); this._storeTrajectoryShown(); } - this._activeTrajectoryWarning = undefined; + this._activeTrajectoryTelemetryData = undefined; })); this._register(CommandsRegistry.registerCommand(CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, (accessor: ServicesAccessor) => this._handleCreditEfficiencyLearnMoreCommand(accessor))); @@ -325,8 +325,8 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { this._showingExhausted = false; - this._activeTrajectoryWarning = warning; - this._logQuotaTrajectoryNudgeShown(warning); + this._activeTrajectoryTelemetryData = this._getQuotaTrajectoryNudgeTelemetryData(warning); + this._logQuotaTrajectoryNudgeShown(this._activeTrajectoryTelemetryData); this._storeTrajectoryShown(); const learnMoreLink = createMarkdownCommandLink({ text: localize('quota.trajectory.learnMore', "Learn more about managing credits"), @@ -346,34 +346,34 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } private async _handleCreditEfficiencyLearnMoreCommand(accessor: ServicesAccessor): Promise { - if (this._activeTrajectoryWarning) { - this._handleQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryWarning); + if (this._activeTrajectoryTelemetryData) { + this._handleQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryTelemetryData); } await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); } - private _handleQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { - this._logQuotaTrajectoryNudgeLinkClicked(warning); + private _handleQuotaTrajectoryNudgeLinkClicked(data: ChatQuotaTrajectoryNudgeEvent): void { + this._logQuotaTrajectoryNudgeLinkClicked(data); this._storeTrajectoryShown(); queueMicrotask(() => this._hideNotification()); } - private _logQuotaTrajectoryNudgeShown(warning: { averageDailyUsage: number; percentUsed: number }): void { + private _logQuotaTrajectoryNudgeShown(data: ChatQuotaTrajectoryNudgeEvent): void { const resetPeriod = this._getTrajectoryPeriodKey(); const signature = resetPeriod ?? 'unknown'; if (signature === this._lastLoggedTrajectoryShownSignature) { return; } this._lastLoggedTrajectoryShownSignature = signature; - this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', this._getQuotaTrajectoryNudgeTelemetryData(warning)); + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', data); } - private _logQuotaTrajectoryNudgeClosed(warning: { averageDailyUsage: number; percentUsed: number }): void { - this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeClosed', this._getQuotaTrajectoryNudgeTelemetryData(warning)); + private _logQuotaTrajectoryNudgeClosed(data: ChatQuotaTrajectoryNudgeEvent): void { + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeClosed', data); } - private _logQuotaTrajectoryNudgeLinkClicked(warning: { averageDailyUsage: number; percentUsed: number }): void { - this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', this._getQuotaTrajectoryNudgeTelemetryData(warning)); + private _logQuotaTrajectoryNudgeLinkClicked(data: ChatQuotaTrajectoryNudgeEvent): void { + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', data); } private _getQuotaTrajectoryNudgeTelemetryData(warning: { averageDailyUsage: number; percentUsed: number }): ChatQuotaTrajectoryNudgeEvent { @@ -405,7 +405,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showExhaustedNotification(): void { this._showingExhausted = true; - this._activeTrajectoryWarning = undefined; + this._activeTrajectoryTelemetryData = undefined; const entitlement = this._chatEntitlementService.entitlement; const quotas = this._chatEntitlementService.quotas; @@ -447,7 +447,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showOverageActivationNotification(): void { this._showingExhausted = true; - this._activeTrajectoryWarning = undefined; + this._activeTrajectoryTelemetryData = undefined; this._setNotification({ id: QUOTA_NOTIFICATION_ID, @@ -465,7 +465,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { this._showingExhausted = false; - this._activeTrajectoryWarning = undefined; + this._activeTrajectoryTelemetryData = undefined; const entitlement = this._chatEntitlementService.entitlement; const quotas = this._chatEntitlementService.quotas; @@ -538,7 +538,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showRateLimitWarning(warning: { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined }): void { this._showingExhausted = false; - this._activeTrajectoryWarning = undefined; + this._activeTrajectoryTelemetryData = undefined; const message = warning.type === 'session' ? localize('rateLimit.session', "You've used {0}% of your session rate limit.", warning.percentUsed) @@ -587,7 +587,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showManagedPlanBlockedNotification(): void { this._showingExhausted = true; - this._activeTrajectoryWarning = undefined; + this._activeTrajectoryTelemetryData = undefined; this._setNotification({ id: QUOTA_NOTIFICATION_ID, @@ -641,7 +641,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _hideNotification(): void { this._showingExhausted = false; - this._activeTrajectoryWarning = undefined; + this._activeTrajectoryTelemetryData = undefined; this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts index a229edb0600176..bc4f1c2791b951 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts @@ -21,8 +21,6 @@ export interface IChatInputNotificationAction { readonly label: string; readonly commandId: string; readonly commandArgs?: unknown[]; - readonly secondary?: boolean; - readonly run?: () => void; } export interface IChatInputNotificationMuteAction { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts index 1eccab331be6bf..57e1488dece95f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts @@ -205,11 +205,11 @@ export class ChatInputNotificationWidget extends Disposable { for (let i = 0; i < notification.actions.length; i++) { const action = notification.actions[i]; - const isSecondary = action.secondary ?? i !== notification.actions.length - 1; + const isLast = i === notification.actions.length - 1; const button = this._contentDisposables.add(new Button(actionsContainer, { ...defaultButtonStyles, - ...(isSecondary ? { + ...(!isLast ? { buttonBackground: undefined, buttonHoverBackground: undefined, buttonForeground: undefined, @@ -219,7 +219,7 @@ export class ChatInputNotificationWidget extends Disposable { buttonSecondaryBorder: undefined, } : {}), supportIcons: true, - secondary: isSecondary, + secondary: !isLast, })); button.element.classList.add('chat-input-notification-action-button'); button.label = action.label; @@ -230,7 +230,6 @@ export class ChatInputNotificationWidget extends Disposable { id: action.commandId, from: 'chatInputNotification', }); - action.run?.(); await this._commandService.executeCommand(action.commandId, ...(action.commandArgs ?? [])); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css index 07a62649cf8265..586a9f35180633 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css @@ -127,7 +127,6 @@ /* Body row: description + actions inline, wraps at small widths */ .chat-input-notification .chat-input-notification-body { display: flex; - flex-wrap: wrap; align-items: flex-start; gap: 8px; min-width: 0; @@ -139,7 +138,7 @@ font-size: var(--vscode-agents-fontSize-label1); line-height: 18px; color: var(--vscode-descriptionForeground); - flex: 1 1 160px; + flex: 1 1 auto; min-width: 0; overflow-wrap: break-word; word-break: break-word; From a51767820a8abcb631de9157afa738346f7f01aa Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 10:11:35 -0700 Subject: [PATCH 16/24] Trim quota nudge to essential lines Remove redundant trajectory-shown signature dedup, duplicate storeTrajectoryShown calls, the redundant terminal baseline capture, dead canUpgradePlan test scaffolding, and cosmetic churn. Inline the single-use link-click handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 21 ++----------------- .../browser/chatQuotaNotification.test.ts | 3 --- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 1db695c61724b6..169f78a73bfb18 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -80,7 +80,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _prevWeeklyPercentUsed: number | undefined; private _trajectoryNudgeEnabled = false; private _trajectoryTreatmentInitialized = false; - private _lastLoggedTrajectoryShownSignature: string | undefined; private _activeTrajectoryTelemetryData: ChatQuotaTrajectoryNudgeEvent | undefined; constructor( @@ -104,7 +103,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } if (this._activeTrajectoryTelemetryData) { this._logQuotaTrajectoryNudgeClosed(this._activeTrajectoryTelemetryData); - this._storeTrajectoryShown(); } this._activeTrajectoryTelemetryData = undefined; })); @@ -249,8 +247,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._showRateLimitWarning(rateLimitWarning); return; } - - this._captureNotificationBaselines(); } // --- Threshold crossing detection ---------------------------------------- @@ -275,7 +271,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._prevQuotaPercentUsed = undefined; return undefined; } - const percentUsed = 100 - snapshot.percentRemaining; const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); this._prevQuotaPercentUsed = percentUsed; @@ -347,24 +342,13 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private async _handleCreditEfficiencyLearnMoreCommand(accessor: ServicesAccessor): Promise { if (this._activeTrajectoryTelemetryData) { - this._handleQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryTelemetryData); + this._logQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryTelemetryData); + queueMicrotask(() => this._hideNotification()); } await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); } - private _handleQuotaTrajectoryNudgeLinkClicked(data: ChatQuotaTrajectoryNudgeEvent): void { - this._logQuotaTrajectoryNudgeLinkClicked(data); - this._storeTrajectoryShown(); - queueMicrotask(() => this._hideNotification()); - } - private _logQuotaTrajectoryNudgeShown(data: ChatQuotaTrajectoryNudgeEvent): void { - const resetPeriod = this._getTrajectoryPeriodKey(); - const signature = resetPeriod ?? 'unknown'; - if (signature === this._lastLoggedTrajectoryShownSignature) { - return; - } - this._lastLoggedTrajectoryShownSignature = signature; this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', data); } @@ -644,5 +628,4 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._activeTrajectoryTelemetryData = undefined; this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); } - } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index d26627169f85c7..1516963c23a244 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -34,7 +34,6 @@ const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; interface IMockQuotas { resetDate?: string; usageBasedBilling?: boolean; - canUpgradePlan?: boolean; chat?: IQuotaSnapshot; completions?: IQuotaSnapshot; premiumChat?: IQuotaSnapshot; @@ -63,7 +62,6 @@ function createMockEntitlementService(opts?: { quotas: { resetDate: opts?.quotas?.resetDate, usageBasedBilling: opts?.quotas?.usageBasedBilling ?? true, - canUpgradePlan: opts?.quotas?.canUpgradePlan, chat: opts?.quotas?.chat, completions: opts?.quotas?.completions, premiumChat: opts?.quotas?.premiumChat, @@ -592,7 +590,6 @@ suite('ChatQuotaNotificationContribution', () => { const { notificationMock } = createContribution({ quotas: { resetDate: makeResetDate(24), - canUpgradePlan: true, usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, From d31c01f9167c005e32a7ac96b4608d4c2faa1ea9 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 11:17:01 -0700 Subject: [PATCH 17/24] Enroll quota trajectory experiment just-in-time Defer the getTreatment exposure until the user has met every condition required to render the nudge, so treatment and control cohorts are assigned at the same point and users who would never see the nudge are not enrolled. Moves the locale check into candidate computation and removes the eager-enrollment gate, restoring the original update flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 85 +++++++------------ .../browser/chatQuotaNotification.test.ts | 4 +- 2 files changed, 34 insertions(+), 55 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 169f78a73bfb18..ef8049c8a4c2b0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -78,8 +78,8 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _prevAdditionalUsageEnabled: boolean | undefined; private _prevSessionPercentUsed: number | undefined; private _prevWeeklyPercentUsed: number | undefined; - private _trajectoryNudgeEnabled = false; - private _trajectoryTreatmentInitialized = false; + private _trajectoryTreatment: 'enabled' | 'control' | undefined; + private _trajectoryAssignmentRequested = false; private _activeTrajectoryTelemetryData: ChatQuotaTrajectoryNudgeEvent | undefined; constructor( @@ -96,7 +96,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._register(this._chatEntitlementService.onDidChangeQuotaRemaining(() => this._update())); this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => this._update())); this._register(this._chatEntitlementService.onDidChangeEntitlement(() => this._update())); - this._register(this._assignmentService.onDidRefetchAssignments(() => { void this._updateTrajectoryTreatment(); })); this._register(this._chatInputNotificationService.onDidDismiss(id => { if (id !== QUOTA_NOTIFICATION_ID) { return; @@ -118,27 +117,17 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } })); - void this._updateTrajectoryTreatment(); this._update(); } - private async _updateTrajectoryTreatment(): Promise { - if (!Language.isDefaultVariant()) { - this._setTrajectoryTreatment(false); - return; - } - - const trajectoryTreatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); - this._setTrajectoryTreatment(trajectoryTreatment === 'enabled'); - } - - private _setTrajectoryTreatment(trajectoryEnabled: boolean): void { - const wasInitialized = this._trajectoryTreatmentInitialized; - this._trajectoryTreatmentInitialized = true; - if (wasInitialized && this._trajectoryNudgeEnabled === trajectoryEnabled) { - return; - } - this._trajectoryNudgeEnabled = trajectoryEnabled; + /** + * Enrolls the user in the trajectory experiment. Called lazily, only once + * the user has met every condition required to render the nudge, so that + * the experiment is not diluted with users who would never be exposed. + */ + private async _resolveTrajectoryTreatment(): Promise { + const treatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); + this._trajectoryTreatment = treatment === 'enabled' ? 'enabled' : 'control'; this._update(); } @@ -215,23 +204,23 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return; } - // Nothing new to show — only hide if the exhausted notification is - // active and the quota is no longer exhausted (state-driven). - if (this._showingExhausted && !this._isQuotaUsedUp()) { - this._hideNotification(); - } - - if (!this._trajectoryTreatmentInitialized) { - this._captureNotificationBaselines(); - return; - } - // Priority 2: Quota approaching threshold if (isQuotaNotificationEligible) { - const trajectoryWarning = this._computeQuotaTrajectoryWarning(); - if (trajectoryWarning) { - this._showQuotaTrajectoryWarning(trajectoryWarning); - return; + const trajectoryCandidate = this._computeQuotaTrajectoryCandidate(); + if (trajectoryCandidate) { + // Enroll only now that every render condition is met, so the + // treatment and control cohorts are assigned at the same point. + if (!this._trajectoryAssignmentRequested) { + this._trajectoryAssignmentRequested = true; + void this._resolveTrajectoryTreatment(); + } + if (this._trajectoryTreatment === undefined) { + return; // assignment pending — don't show lower-priority notifications yet + } + if (this._trajectoryTreatment === 'enabled') { + this._showQuotaTrajectoryWarning(trajectoryCandidate); + return; + } } const quotaWarning = this._computeQuotaWarning(); @@ -247,24 +236,16 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._showRateLimitWarning(rateLimitWarning); return; } - } - - // --- Threshold crossing detection ---------------------------------------- - - private _captureNotificationBaselines(): void { - const snapshot = this._getRelevantSnapshot(); - this._prevQuotaPercentUsed = !snapshot || snapshot.unlimited ? undefined : 100 - snapshot.percentRemaining; - this._prevSessionPercentUsed = this._getRateLimitPercentUsed(this._chatEntitlementService.quotas.sessionRateLimit); - this._prevWeeklyPercentUsed = this._getRateLimitPercentUsed(this._chatEntitlementService.quotas.weeklyRateLimit); - } - private _getRateLimitPercentUsed(snapshot: IRateLimitSnapshot | undefined): number | undefined { - if (!snapshot || snapshot.unlimited) { - return undefined; + // Nothing new to show — only hide if the exhausted notification is + // active and the quota is no longer exhausted (state-driven). + if (this._showingExhausted && !this._isQuotaUsedUp()) { + this._hideNotification(); } - return 100 - snapshot.percentRemaining; } + // --- Threshold crossing detection ---------------------------------------- + private _computeQuotaWarning(): { percentUsed: number } | undefined { const snapshot = this._getRelevantSnapshot(); if (!snapshot || snapshot.unlimited) { @@ -280,8 +261,8 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } - private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined { - if (!this._trajectoryNudgeEnabled || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryShownInCurrentPeriod()) { + private _computeQuotaTrajectoryCandidate(): { averageDailyUsage: number; percentUsed: number } | undefined { + if (!Language.isDefaultVariant() || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryShownInCurrentPeriod()) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 1516963c23a244..1744ec128c21b8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -464,7 +464,7 @@ suite('ChatQuotaNotificationContribution', () => { await flushPromises(); - // Initial treatment resolution stores 75% as the baseline without notifying. + // First data arrival stores 75% as the baseline without notifying. assert.strictEqual(notificationMock.getNotification(), undefined); }); @@ -611,8 +611,6 @@ suite('ChatQuotaNotificationContribution', () => { }, }, { trajectoryTreatment: 'enabled' }); - await flushPromises(); - assignmentMock.onDidRefetchAssignments.fire(); await flushPromises(); assert.deepStrictEqual({ From fe0a944dcbd26f2f26bb91e2a2f8f226357f14d5 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 12:54:06 -0700 Subject: [PATCH 18/24] Consolidate trajectory enrollment into warning computation Move the just-in-time experiment enrollment into _computeQuotaTrajectoryWarning so _update stays lean. The method dispatches the async assignment once every render condition is met and returns a warning only for the treatment cohort; while assignment is pending it returns undefined and the remainder of _update falls through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 37 +++++++++---------- .../browser/chatQuotaNotification.test.ts | 7 ++-- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index ef8049c8a4c2b0..6a5fc11fd93bc2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -206,21 +206,10 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo // Priority 2: Quota approaching threshold if (isQuotaNotificationEligible) { - const trajectoryCandidate = this._computeQuotaTrajectoryCandidate(); - if (trajectoryCandidate) { - // Enroll only now that every render condition is met, so the - // treatment and control cohorts are assigned at the same point. - if (!this._trajectoryAssignmentRequested) { - this._trajectoryAssignmentRequested = true; - void this._resolveTrajectoryTreatment(); - } - if (this._trajectoryTreatment === undefined) { - return; // assignment pending — don't show lower-priority notifications yet - } - if (this._trajectoryTreatment === 'enabled') { - this._showQuotaTrajectoryWarning(trajectoryCandidate); - return; - } + const trajectoryWarning = this._computeQuotaTrajectoryWarning(); + if (trajectoryWarning) { + this._showQuotaTrajectoryWarning(trajectoryWarning); + return; } const quotaWarning = this._computeQuotaWarning(); @@ -261,7 +250,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } - private _computeQuotaTrajectoryCandidate(): { averageDailyUsage: number; percentUsed: number } | undefined { + private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined { if (!Language.isDefaultVariant() || !this._isTrajectoryEligibleEntitlement() || this._isTrajectoryShownInCurrentPeriod()) { return undefined; } @@ -293,10 +282,20 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } const averageDailyUsage = percentUsed / elapsedDays; - if (averageDailyUsage >= TRAJECTORY_DAILY_USAGE_THRESHOLD) { - return { averageDailyUsage, percentUsed }; + if (averageDailyUsage < TRAJECTORY_DAILY_USAGE_THRESHOLD) { + return undefined; } - return undefined; + + // Every render condition is met. Enroll in the experiment now + // (just-in-time) so treatment and control cohorts are assigned at the + // same point and users who would never see the nudge are not exposed. + // While assignment is pending, fall through; _update re-runs once it + // resolves and only the treatment cohort then renders the nudge. + if (!this._trajectoryAssignmentRequested) { + this._trajectoryAssignmentRequested = true; + void this._resolveTrajectoryTreatment(); + } + return this._trajectoryTreatment === 'enabled' ? { averageDailyUsage, percentUsed } : undefined; } private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 1744ec128c21b8..20d3b7c45291f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -735,22 +735,21 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); }); - test('does not show lower priority warnings before treatment resolves', async () => { + test('shows trajectory nudge only after treatment resolves', async () => { let resolveTreatment: ((value: string | undefined) => void) | undefined; const trajectoryTreatment = new Promise(resolve => { resolveTreatment = resolve; }); - const { entitlementMock, notificationMock } = createContribution({ + const { notificationMock } = createContribution({ entitlement: ChatEntitlement.Pro, quotas: { resetDate: makeResetDate(24), usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), - sessionRateLimit: makeRateLimitSnapshot(60), }, }, { trajectoryTreatment }); - updateQuotas(entitlementMock, { sessionRateLimit: makeRateLimitSnapshot(25) }); + await flushPromises(); assert.strictEqual(notificationMock.getNotification(), undefined); assert.ok(resolveTreatment); From 83213e5e4fa354df1c5137bdf01b0482cdb7149d Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 12:59:17 -0700 Subject: [PATCH 19/24] Restoring comment --- src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 6a5fc11fd93bc2..dcd52a37d918c6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -117,6 +117,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } })); + // Check initial state in case quota is already exhausted at startup this._update(); } From b6c2560f3efa4d52eb0791d14355daace0d8f34c Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 13:24:34 -0700 Subject: [PATCH 20/24] Do not treat unassigned trajectory treatment as control getTreatment returns undefined when the user is not in the experiment flight (or assignments are unavailable), which is distinct from being assigned to a control cohort. Store the raw treatment value and render the nudge only on an explicit 'enabled' value, instead of coercing a missing assignment into a synthetic 'control' enrollment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 11 +++++--- .../browser/chatQuotaNotification.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index dcd52a37d918c6..512000a441b65d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -78,7 +78,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _prevAdditionalUsageEnabled: boolean | undefined; private _prevSessionPercentUsed: number | undefined; private _prevWeeklyPercentUsed: number | undefined; - private _trajectoryTreatment: 'enabled' | 'control' | undefined; + private _trajectoryTreatment: string | undefined; private _trajectoryAssignmentRequested = false; private _activeTrajectoryTelemetryData: ChatQuotaTrajectoryNudgeEvent | undefined; @@ -125,10 +125,15 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo * Enrolls the user in the trajectory experiment. Called lazily, only once * the user has met every condition required to render the nudge, so that * the experiment is not diluted with users who would never be exposed. + * + * Stores the raw treatment value. `undefined` means the user is not + * assigned to the flight (or assignments are not available); only an + * explicit `'enabled'` treatment renders the nudge. We deliberately do not + * coerce a missing assignment into a synthetic "control" value, since that + * would assume an enrollment that may not exist. */ private async _resolveTrajectoryTreatment(): Promise { - const treatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); - this._trajectoryTreatment = treatment === 'enabled' ? 'enabled' : 'control'; + this._trajectoryTreatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); this._update(); } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 20d3b7c45291f6..43998dc7ad103c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -622,6 +622,31 @@ suite('ChatQuotaNotificationContribution', () => { }); }); + test('does not show when user is eligible but not assigned to the experiment', async () => { + // No treatment configured: getTreatment resolves to undefined, i.e. + // the user is not in the flight. This must not be treated as control + // enrollment, but it should still attempt exposure since the user met + // every render condition. + const { assignmentMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }); + + await flushPromises(); + + assert.deepStrictEqual({ + treatments: assignmentMock.getTreatmentCalls, + notification: notificationMock.getNotification(), + }, { + treatments: ['chatQuotaTrajectoryNudge'], + notification: undefined, + }); + }); + test('does not show outside monthly usage window', async () => { const results = []; for (const percentRemaining of [91, 64]) { From 0642356b8fc2ee262b4dc57990ecbbd84928a221 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 13:40:47 -0700 Subject: [PATCH 21/24] Emit enrollment telemetry only when assigned to the experiment flight Log a chatQuotaTrajectoryNudgeEnrolled event with the resolved treatment value when the experiment service assigns the user to a flight (treatment is defined), to measure experiment exposure across both arms. No telemetry is emitted when TAS returns no assignment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 28 +++++- .../browser/chatQuotaNotification.test.ts | 86 +++++++++++++++++-- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 512000a441b65d..bb7414e1b9dab7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -50,6 +50,18 @@ type ChatQuotaTrajectoryNudgeClassification = { percentUsed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The monthly quota percentage used when the quota trajectory nudge event was logged.' }; }; +type ChatQuotaTrajectoryNudgeEnrollmentEvent = { + treatment: string; + entitlement: string; +}; + +type ChatQuotaTrajectoryNudgeEnrollmentClassification = { + owner: 'rfeltis'; + comment: 'Tracks when a user is assigned to a flight for the chat quota trajectory nudge experiment, to measure experiment exposure.'; + treatment: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The treatment value assigned by the experiment service.' }; + entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user entitlement when the user was assigned to the experiment flight.' }; +}; + /** * Core-side workbench contribution that shows chat input notifications for * quota exhaustion and quota-approaching thresholds. @@ -130,10 +142,15 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo * assigned to the flight (or assignments are not available); only an * explicit `'enabled'` treatment renders the nudge. We deliberately do not * coerce a missing assignment into a synthetic "control" value, since that - * would assume an enrollment that may not exist. + * would assume an enrollment that may not exist. Enrollment telemetry is + * emitted only when the user is actually assigned to a flight. */ private async _resolveTrajectoryTreatment(): Promise { - this._trajectoryTreatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); + const treatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); + this._trajectoryTreatment = treatment; + if (treatment !== undefined) { + this._logQuotaTrajectoryNudgeEnrolled(treatment); + } this._update(); } @@ -346,6 +363,13 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', data); } + private _logQuotaTrajectoryNudgeEnrolled(treatment: string): void { + this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeEnrolled', { + treatment, + entitlement: ChatEntitlement[this._chatEntitlementService.entitlement], + }); + } + private _getQuotaTrajectoryNudgeTelemetryData(warning: { averageDailyUsage: number; percentUsed: number }): ChatQuotaTrajectoryNudgeEvent { return { severity: 'info', diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 43998dc7ad103c..373d497cf0b593 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -801,15 +801,24 @@ suite('ChatQuotaNotificationContribution', () => { await flushPromises(); entitlementMock.onDidChangeQuotaRemaining.fire(); - assert.deepStrictEqual(telemetryService.events, [{ - name: 'chatQuotaTrajectoryNudgeShown', - data: { - severity: 'info', - entitlement: 'Pro', - averageDailyUsage: 4.67, - percentUsed: 28, + assert.deepStrictEqual(telemetryService.events, [ + { + name: 'chatQuotaTrajectoryNudgeEnrolled', + data: { + treatment: 'enabled', + entitlement: 'Pro', + }, + }, + { + name: 'chatQuotaTrajectoryNudgeShown', + data: { + severity: 'info', + entitlement: 'Pro', + averageDailyUsage: 4.67, + percentUsed: 28, + }, }, - }]); + ]); }); test('logs close telemetry', async () => { @@ -827,6 +836,13 @@ suite('ChatQuotaNotificationContribution', () => { notificationMock.dismiss(); assert.deepStrictEqual(telemetryService.events, [ + { + name: 'chatQuotaTrajectoryNudgeEnrolled', + data: { + treatment: 'enabled', + entitlement: 'Pro', + }, + }, { name: 'chatQuotaTrajectoryNudgeShown', data: { @@ -867,6 +883,13 @@ suite('ChatQuotaNotificationContribution', () => { opened: opened.map(uri => uri.toString()), }, { events: [ + { + name: 'chatQuotaTrajectoryNudgeEnrolled', + data: { + treatment: 'enabled', + entitlement: 'Pro', + }, + }, { name: 'chatQuotaTrajectoryNudgeShown', data: { @@ -890,6 +913,53 @@ suite('ChatQuotaNotificationContribution', () => { }); }); + test('logs enrollment telemetry for control assignment without showing nudge', async () => { + const telemetryService = new TestTelemetryService(); + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { trajectoryTreatment: 'control', telemetryService }); + + await flushPromises(); + + assert.deepStrictEqual({ + events: telemetryService.events, + notification: notificationMock.getNotification(), + }, { + events: [{ + name: 'chatQuotaTrajectoryNudgeEnrolled', + data: { treatment: 'control', entitlement: 'Pro' }, + }], + notification: undefined, + }); + }); + + test('does not log enrollment telemetry when not assigned to a flight', async () => { + const telemetryService = new TestTelemetryService(); + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { + resetDate: makeResetDate(24), + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(72), + }, + }, { telemetryService }); // no treatment configured -> not assigned to the flight + + await flushPromises(); + + assert.deepStrictEqual({ + events: telemetryService.events, + notification: notificationMock.getNotification(), + }, { + events: [], + notification: undefined, + }); + }); + test('action click dismisses trajectory nudge for the quota period', async () => { const { entitlementMock, notificationMock } = createContribution({ entitlement: ChatEntitlement.Pro, From c735be949c713c78c2eed29bcae60003cd34e48e Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Thu, 18 Jun 2026 14:54:31 -0700 Subject: [PATCH 22/24] Use config.-prefixed boolean treatment key for quota trajectory nudge experiment ExP for VS Code requires experiment treatment keys to be prefixed with config. The treatment is a simple yes/no, so use a boolean type instead of a string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 24 ++++----- .../browser/chatQuotaNotification.test.ts | 50 +++++++++---------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index bb7414e1b9dab7..cf5728ef7bd08b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -29,7 +29,7 @@ const TRAJECTORY_MINIMUM_PERCENT_USED = 10; const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; const BILLING_PERIOD_DAYS = 30; const MS_PER_DAY = 24 * 60 * 60 * 1000; -const TRAJECTORY_TREATMENT = 'chatQuotaTrajectoryNudge'; +const TRAJECTORY_TREATMENT = 'config.chatQuotaTrajectoryNudge'; const TRAJECTORY_SHOWN_STORAGE_KEY = 'chat.quotaTrajectory.shownPeriod'; const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; @@ -51,14 +51,14 @@ type ChatQuotaTrajectoryNudgeClassification = { }; type ChatQuotaTrajectoryNudgeEnrollmentEvent = { - treatment: string; + treatment: boolean; entitlement: string; }; type ChatQuotaTrajectoryNudgeEnrollmentClassification = { owner: 'rfeltis'; comment: 'Tracks when a user is assigned to a flight for the chat quota trajectory nudge experiment, to measure experiment exposure.'; - treatment: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The treatment value assigned by the experiment service.' }; + treatment: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The treatment value assigned by the experiment service (true for the treatment arm, false for control).' }; entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user entitlement when the user was assigned to the experiment flight.' }; }; @@ -90,7 +90,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _prevAdditionalUsageEnabled: boolean | undefined; private _prevSessionPercentUsed: number | undefined; private _prevWeeklyPercentUsed: number | undefined; - private _trajectoryTreatment: string | undefined; + private _trajectoryTreatment: boolean | undefined; private _trajectoryAssignmentRequested = false; private _activeTrajectoryTelemetryData: ChatQuotaTrajectoryNudgeEvent | undefined; @@ -139,14 +139,14 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo * the experiment is not diluted with users who would never be exposed. * * Stores the raw treatment value. `undefined` means the user is not - * assigned to the flight (or assignments are not available); only an - * explicit `'enabled'` treatment renders the nudge. We deliberately do not - * coerce a missing assignment into a synthetic "control" value, since that - * would assume an enrollment that may not exist. Enrollment telemetry is - * emitted only when the user is actually assigned to a flight. + * assigned to the flight (or assignments are not available); only a `true` + * treatment renders the nudge. We deliberately do not coerce a missing + * assignment into a synthetic "control" value, since that would assume an + * enrollment that may not exist. Enrollment telemetry is emitted only when + * the user is actually assigned to a flight. */ private async _resolveTrajectoryTreatment(): Promise { - const treatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); + const treatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); this._trajectoryTreatment = treatment; if (treatment !== undefined) { this._logQuotaTrajectoryNudgeEnrolled(treatment); @@ -318,7 +318,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._trajectoryAssignmentRequested = true; void this._resolveTrajectoryTreatment(); } - return this._trajectoryTreatment === 'enabled' ? { averageDailyUsage, percentUsed } : undefined; + return this._trajectoryTreatment === true ? { averageDailyUsage, percentUsed } : undefined; } private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { @@ -363,7 +363,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', data); } - private _logQuotaTrajectoryNudgeEnrolled(treatment: string): void { + private _logQuotaTrajectoryNudgeEnrolled(treatment: boolean): void { this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeEnrolled', { treatment, entitlement: ChatEntitlement[this._chatEntitlementService.entitlement], diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 373d497cf0b593..870052d2263efe 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -154,7 +154,7 @@ function createMockNotificationService() { }; } -function createMockAssignmentService(treatments?: Readonly>>) { +function createMockAssignmentService(treatments?: Readonly>>) { const onDidRefetchAssignments = new Emitter(); const getTreatmentCalls: string[] = []; const service: IWorkbenchAssignmentService = { @@ -248,11 +248,11 @@ suite('ChatQuotaNotificationContribution', () => { sinon.restore(); }); - function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string; trajectoryTreatment?: string | Promise; telemetryService?: ITelemetryService }) { + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string; trajectoryTreatment?: boolean | Promise; telemetryService?: ITelemetryService }) { const entitlementMock = createMockEntitlementService(entitlementOpts); const notificationMock = createMockNotificationService(); const assignmentMock = createMockAssignmentService({ - chatQuotaTrajectoryNudge: modelOpts?.trajectoryTreatment, + 'config.chatQuotaTrajectoryNudge': modelOpts?.trajectoryTreatment, }); const contextKeyService = store.add(new MockContextKeyService()); const storageService = store.add(new InMemoryStorageService()); @@ -609,7 +609,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); @@ -642,7 +642,7 @@ suite('ChatQuotaNotificationContribution', () => { treatments: assignmentMock.getTreatmentCalls, notification: notificationMock.getNotification(), }, { - treatments: ['chatQuotaTrajectoryNudge'], + treatments: ['config.chatQuotaTrajectoryNudge'], notification: undefined, }); }); @@ -657,7 +657,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(percentRemaining), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); @@ -675,7 +675,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); @@ -708,7 +708,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); @@ -723,7 +723,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); @@ -738,7 +738,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(78), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); @@ -753,7 +753,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); @@ -761,8 +761,8 @@ suite('ChatQuotaNotificationContribution', () => { }); test('shows trajectory nudge only after treatment resolves', async () => { - let resolveTreatment: ((value: string | undefined) => void) | undefined; - const trajectoryTreatment = new Promise(resolve => { + let resolveTreatment: ((value: boolean | undefined) => void) | undefined; + const trajectoryTreatment = new Promise(resolve => { resolveTreatment = resolve; }); const { notificationMock } = createContribution({ @@ -778,7 +778,7 @@ suite('ChatQuotaNotificationContribution', () => { assert.strictEqual(notificationMock.getNotification(), undefined); assert.ok(resolveTreatment); - resolveTreatment('enabled'); + resolveTreatment(true); await flushPromises(); const notification = notificationMock.getNotification(); @@ -796,7 +796,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled', telemetryService }); + }, { trajectoryTreatment: true, telemetryService }); await flushPromises(); entitlementMock.onDidChangeQuotaRemaining.fire(); @@ -805,7 +805,7 @@ suite('ChatQuotaNotificationContribution', () => { { name: 'chatQuotaTrajectoryNudgeEnrolled', data: { - treatment: 'enabled', + treatment: true, entitlement: 'Pro', }, }, @@ -830,7 +830,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled', telemetryService }); + }, { trajectoryTreatment: true, telemetryService }); await flushPromises(); notificationMock.dismiss(); @@ -839,7 +839,7 @@ suite('ChatQuotaNotificationContribution', () => { { name: 'chatQuotaTrajectoryNudgeEnrolled', data: { - treatment: 'enabled', + treatment: true, entitlement: 'Pro', }, }, @@ -873,7 +873,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled', telemetryService }); + }, { trajectoryTreatment: true, telemetryService }); await flushPromises(); const opened = await runCreditEfficiencyLearnMoreCommand(); @@ -886,7 +886,7 @@ suite('ChatQuotaNotificationContribution', () => { { name: 'chatQuotaTrajectoryNudgeEnrolled', data: { - treatment: 'enabled', + treatment: true, entitlement: 'Pro', }, }, @@ -922,7 +922,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'control', telemetryService }); + }, { trajectoryTreatment: false, telemetryService }); await flushPromises(); @@ -932,7 +932,7 @@ suite('ChatQuotaNotificationContribution', () => { }, { events: [{ name: 'chatQuotaTrajectoryNudgeEnrolled', - data: { treatment: 'control', entitlement: 'Pro' }, + data: { treatment: false, entitlement: 'Pro' }, }], notification: undefined, }); @@ -968,7 +968,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); assert.ok(notificationMock.getNotification()); @@ -992,7 +992,7 @@ suite('ChatQuotaNotificationContribution', () => { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); assert.ok(notificationMock.getNotification()); @@ -1013,7 +1013,7 @@ suite('ChatQuotaNotificationContribution', () => { premiumChat: makeQuotaSnapshot(72), chat: makeQuotaSnapshot(72), }, - }, { trajectoryTreatment: 'enabled' }); + }, { trajectoryTreatment: true }); await flushPromises(); From 8adc7cec657c3a00cc633900ca48646a96d4877d Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Fri, 19 Jun 2026 09:53:07 -0700 Subject: [PATCH 23/24] Align quota nudge telemetry with generic notification telemetry Remove trajectory nudge shown and dismissed interaction telemetry now that generic chat input notification telemetry covers those interactions. Keep the learn more link click event because it is specific to this nudge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 14 +-- .../browser/chatQuotaNotification.test.ts | 86 ------------------- 2 files changed, 1 insertion(+), 99 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index cf5728ef7bd08b..159be381d82fc2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -43,7 +43,7 @@ type ChatQuotaTrajectoryNudgeEvent = { type ChatQuotaTrajectoryNudgeClassification = { owner: 'rfeltis'; - comment: 'Tracks when the chat quota trajectory nudge is shown, closed, and when users click its learn more link.'; + comment: 'Tracks when users click the chat quota trajectory nudge learn more link.'; severity: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The severity of the quota trajectory nudge.' }; entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user entitlement when the quota trajectory nudge event was logged.' }; averageDailyUsage: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The average daily monthly quota usage percentage that caused the nudge.' }; @@ -112,9 +112,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo if (id !== QUOTA_NOTIFICATION_ID) { return; } - if (this._activeTrajectoryTelemetryData) { - this._logQuotaTrajectoryNudgeClosed(this._activeTrajectoryTelemetryData); - } this._activeTrajectoryTelemetryData = undefined; })); this._register(CommandsRegistry.registerCommand(CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, (accessor: ServicesAccessor) => this._handleCreditEfficiencyLearnMoreCommand(accessor))); @@ -324,7 +321,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { this._showingExhausted = false; this._activeTrajectoryTelemetryData = this._getQuotaTrajectoryNudgeTelemetryData(warning); - this._logQuotaTrajectoryNudgeShown(this._activeTrajectoryTelemetryData); this._storeTrajectoryShown(); const learnMoreLink = createMarkdownCommandLink({ text: localize('quota.trajectory.learnMore', "Learn more about managing credits"), @@ -351,14 +347,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); } - private _logQuotaTrajectoryNudgeShown(data: ChatQuotaTrajectoryNudgeEvent): void { - this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeShown', data); - } - - private _logQuotaTrajectoryNudgeClosed(data: ChatQuotaTrajectoryNudgeEvent): void { - this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeClosed', data); - } - private _logQuotaTrajectoryNudgeLinkClicked(data: ChatQuotaTrajectoryNudgeEvent): void { this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeLinkClicked', data); } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 870052d2263efe..2a0bfda0ccebb7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -787,83 +787,6 @@ suite('ChatQuotaNotificationContribution', () => { assert.ok(typeof message !== 'string' && message.value.includes('monthly allowance')); }); - test('logs shown telemetry once per quota period', async () => { - const telemetryService = new TestTelemetryService(); - const { entitlementMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: true, telemetryService }); - - await flushPromises(); - entitlementMock.onDidChangeQuotaRemaining.fire(); - - assert.deepStrictEqual(telemetryService.events, [ - { - name: 'chatQuotaTrajectoryNudgeEnrolled', - data: { - treatment: true, - entitlement: 'Pro', - }, - }, - { - name: 'chatQuotaTrajectoryNudgeShown', - data: { - severity: 'info', - entitlement: 'Pro', - averageDailyUsage: 4.67, - percentUsed: 28, - }, - }, - ]); - }); - - test('logs close telemetry', async () => { - const telemetryService = new TestTelemetryService(); - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: true, telemetryService }); - - await flushPromises(); - notificationMock.dismiss(); - - assert.deepStrictEqual(telemetryService.events, [ - { - name: 'chatQuotaTrajectoryNudgeEnrolled', - data: { - treatment: true, - entitlement: 'Pro', - }, - }, - { - name: 'chatQuotaTrajectoryNudgeShown', - data: { - severity: 'info', - entitlement: 'Pro', - averageDailyUsage: 4.67, - percentUsed: 28, - }, - }, - { - name: 'chatQuotaTrajectoryNudgeClosed', - data: { - severity: 'info', - entitlement: 'Pro', - averageDailyUsage: 4.67, - percentUsed: 28, - }, - }, - ]); - }); - test('logs link click telemetry', async () => { const telemetryService = new TestTelemetryService(); createContribution({ @@ -890,15 +813,6 @@ suite('ChatQuotaNotificationContribution', () => { entitlement: 'Pro', }, }, - { - name: 'chatQuotaTrajectoryNudgeShown', - data: { - severity: 'info', - entitlement: 'Pro', - averageDailyUsage: 4.67, - percentUsed: 28, - }, - }, { name: 'chatQuotaTrajectoryNudgeLinkClicked', data: { From 4cbd84ff0c0d51f354b4d0571be27920f679ec5f Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Fri, 19 Jun 2026 15:16:36 -0700 Subject: [PATCH 24/24] Address quota trajectory nudge review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 159be381d82fc2..a42a2eb4d1e425 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -24,15 +24,17 @@ import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotifi const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; const THRESHOLDS = [50, 75, 90, 95]; -const TRAJECTORY_DAILY_USAGE_THRESHOLD = 4.5; -const TRAJECTORY_MINIMUM_PERCENT_USED = 10; -const TRAJECTORY_MAXIMUM_PERCENT_USED = 35; -const BILLING_PERIOD_DAYS = 30; -const MS_PER_DAY = 24 * 60 * 60 * 1000; -const TRAJECTORY_TREATMENT = 'config.chatQuotaTrajectoryNudge'; -const TRAJECTORY_SHOWN_STORAGE_KEY = 'chat.quotaTrajectory.shownPeriod'; -const CREDIT_EFFICIENCY_LEARN_MORE_URL = 'https://aka.ms/token-usage-tips'; -const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; +const TRAJECTORY_NUDGE_SPEC = { + treatment: 'config.chatQuotaTrajectoryNudge', + shownStorageKey: 'chat.quotaTrajectory.shownPeriod', + averageDailyUsageThreshold: 4.5, + minimumPercentUsed: 10, + maximumPercentUsed: 35, + billingPeriodDays: 30, + msPerDay: 24 * 60 * 60 * 1000, + learnMoreUrl: 'https://aka.ms/token-usage-tips', + learnMoreCommandId: 'workbench.action.chat.learnMoreAboutCreditUsage', +} as const; type ChatQuotaTrajectoryNudgeEvent = { severity: 'info'; @@ -108,13 +110,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._register(this._chatEntitlementService.onDidChangeQuotaRemaining(() => this._update())); this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => this._update())); this._register(this._chatEntitlementService.onDidChangeEntitlement(() => this._update())); - this._register(this._chatInputNotificationService.onDidDismiss(id => { - if (id !== QUOTA_NOTIFICATION_ID) { - return; - } - this._activeTrajectoryTelemetryData = undefined; - })); - this._register(CommandsRegistry.registerCommand(CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, (accessor: ServicesAccessor) => this._handleCreditEfficiencyLearnMoreCommand(accessor))); + this._register(CommandsRegistry.registerCommand(TRAJECTORY_NUDGE_SPEC.learnMoreCommandId, (accessor: ServicesAccessor) => this._handleCreditEfficiencyLearnMoreCommand(accessor))); // Re-evaluate when the selected model changes (e.g. switching between Copilot and BYOK). // The chatModelId context key is widget-scoped and may not bubble to the global @@ -143,7 +139,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo * the user is actually assigned to a flight. */ private async _resolveTrajectoryTreatment(): Promise { - const treatment = await this._assignmentService.getTreatment(TRAJECTORY_TREATMENT); + const treatment = await this._assignmentService.getTreatment(TRAJECTORY_NUDGE_SPEC.treatment); this._trajectoryTreatment = treatment; if (treatment !== undefined) { this._logQuotaTrajectoryNudgeEnrolled(treatment); @@ -290,19 +286,19 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } - const periodStartTime = resetTime - (BILLING_PERIOD_DAYS * MS_PER_DAY); - const elapsedDays = Math.max(0, (Date.now() - periodStartTime) / MS_PER_DAY); + const periodStartTime = resetTime - (TRAJECTORY_NUDGE_SPEC.billingPeriodDays * TRAJECTORY_NUDGE_SPEC.msPerDay); + const elapsedDays = Math.max(0, (Date.now() - periodStartTime) / TRAJECTORY_NUDGE_SPEC.msPerDay); if (elapsedDays <= 0) { return undefined; } const percentUsed = 100 - snapshot.percentRemaining; - if (percentUsed < TRAJECTORY_MINIMUM_PERCENT_USED || percentUsed > TRAJECTORY_MAXIMUM_PERCENT_USED) { + if (percentUsed < TRAJECTORY_NUDGE_SPEC.minimumPercentUsed || percentUsed > TRAJECTORY_NUDGE_SPEC.maximumPercentUsed) { return undefined; } const averageDailyUsage = percentUsed / elapsedDays; - if (averageDailyUsage < TRAJECTORY_DAILY_USAGE_THRESHOLD) { + if (averageDailyUsage < TRAJECTORY_NUDGE_SPEC.averageDailyUsageThreshold) { return undefined; } @@ -324,14 +320,14 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._storeTrajectoryShown(); const learnMoreLink = createMarkdownCommandLink({ text: localize('quota.trajectory.learnMore', "Learn more about managing credits"), - id: CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, + id: TRAJECTORY_NUDGE_SPEC.learnMoreCommandId, tooltip: localize('quota.trajectory.learnMoreTooltip', "Learn more about managing credits"), }); this._setNotification({ id: QUOTA_NOTIFICATION_ID, severity: ChatInputNotificationSeverity.Info, - message: new MarkdownString(localize({ key: 'quota.trajectory.message', comment: ['{Locked="]({0})"}'] }, "Based on recent usage, your monthly allowance may run out before it resets. {0}", learnMoreLink), { isTrusted: { enabledCommands: [CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID] } }), + message: new MarkdownString(localize({ key: 'quota.trajectory.message', comment: ['{Locked="]({0})"}'] }, "Based on recent usage, your monthly allowance may run out before it resets. {0}", learnMoreLink), { isTrusted: { enabledCommands: [TRAJECTORY_NUDGE_SPEC.learnMoreCommandId] } }), description: undefined, actions: [], dismissible: true, @@ -344,7 +340,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._logQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryTelemetryData); queueMicrotask(() => this._hideNotification()); } - await accessor.get(IOpenerService).open(URI.parse(CREDIT_EFFICIENCY_LEARN_MORE_URL)); + await accessor.get(IOpenerService).open(URI.parse(TRAJECTORY_NUDGE_SPEC.learnMoreUrl)); } private _logQuotaTrajectoryNudgeLinkClicked(data: ChatQuotaTrajectoryNudgeEvent): void { @@ -607,13 +603,13 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _isTrajectoryShownInCurrentPeriod(): boolean { const periodKey = this._getTrajectoryPeriodKey(); - return !!periodKey && this._storageService.get(TRAJECTORY_SHOWN_STORAGE_KEY, StorageScope.APPLICATION) === periodKey; + return !!periodKey && this._storageService.get(TRAJECTORY_NUDGE_SPEC.shownStorageKey, StorageScope.APPLICATION) === periodKey; } private _storeTrajectoryShown(): void { const periodKey = this._getTrajectoryPeriodKey(); if (periodKey) { - this._storageService.store(TRAJECTORY_SHOWN_STORAGE_KEY, periodKey, StorageScope.APPLICATION, StorageTarget.USER); + this._storageService.store(TRAJECTORY_NUDGE_SPEC.shownStorageKey, periodKey, StorageScope.APPLICATION, StorageTarget.USER); } }