Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9d9d772
Add chat quota trajectory nudge
rfeltis Jun 9, 2026
50849ff
Limit chat input notification CSS changes
rfeltis Jun 9, 2026
138b83d
Dismiss quota trajectory nudge on action
rfeltis Jun 9, 2026
f1c8d36
Trim redundant quota trajectory test
rfeltis Jun 9, 2026
9093799
Update quota trajectory notification link
rfeltis Jun 16, 2026
635920c
Align quota trajectory nudge requirements
rfeltis Jun 17, 2026
da09f7a
Exclude Edu from quota trajectory nudge
rfeltis Jun 17, 2026
f54179c
Instrument quota trajectory nudge telemetry
rfeltis Jun 17, 2026
04b5c2b
Include Max in quota trajectory nudge
rfeltis Jun 17, 2026
e24e04b
Address quota nudge review feedback
rfeltis Jun 17, 2026
148da64
Integrate quota trajectory notification levels
rfeltis Jun 18, 2026
d08dc2e
Gate quota nudge experiment by locale
rfeltis Jun 18, 2026
a89402c
Simplify quota nudge locale guard
rfeltis Jun 18, 2026
2afe123
Remove quota nudge notification kind
rfeltis Jun 18, 2026
91fbb3f
Address quota nudge PR comments
rfeltis Jun 18, 2026
a517678
Trim quota nudge to essential lines
rfeltis Jun 18, 2026
d31c01f
Enroll quota trajectory experiment just-in-time
rfeltis Jun 18, 2026
fe0a944
Consolidate trajectory enrollment into warning computation
rfeltis Jun 18, 2026
83213e5
Restoring comment
rfeltis Jun 18, 2026
b6c2560
Do not treat unassigned trajectory treatment as control
rfeltis Jun 18, 2026
0642356
Emit enrollment telemetry only when assigned to the experiment flight
rfeltis Jun 18, 2026
c735be9
Use config.-prefixed boolean treatment key for quota trajectory nudge…
rfeltis Jun 18, 2026
8adc7ce
Align quota nudge telemetry with generic notification telemetry
rfeltis Jun 19, 2026
4cbd84f
Address quota trajectory nudge review feedback
rfeltis Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 214 additions & 2 deletions src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
* 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 { 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';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.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';
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';
Expand All @@ -16,6 +24,45 @@ import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotifi

const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus';
const THRESHOLDS = [50, 75, 90, 95];
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';
entitlement: string;
averageDailyUsage: number;
percentUsed: number;
};

type ChatQuotaTrajectoryNudgeClassification = {
owner: 'rfeltis';
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.' };
percentUsed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The monthly quota percentage used when the quota trajectory nudge event was logged.' };
};

type ChatQuotaTrajectoryNudgeEnrollmentEvent = {
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 (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.' };
};

/**
* Core-side workbench contribution that shows chat input notifications for
Expand Down Expand Up @@ -45,19 +92,25 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo
private _prevAdditionalUsageEnabled: boolean | undefined;
private _prevSessionPercentUsed: number | undefined;
private _prevWeeklyPercentUsed: number | undefined;
private _trajectoryTreatment: boolean | undefined;
private _trajectoryAssignmentRequested = false;
private _activeTrajectoryTelemetryData: ChatQuotaTrajectoryNudgeEvent | undefined;

constructor(
@IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService,
@IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService,
@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(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
Expand All @@ -73,6 +126,27 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo
this._update();
}

/**
* 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 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<void> {
const treatment = await this._assignmentService.getTreatment<boolean>(TRAJECTORY_NUDGE_SPEC.treatment);
this._trajectoryTreatment = treatment;
if (treatment !== undefined) {
this._logQuotaTrajectoryNudgeEnrolled(treatment);
}
this._update();
}

private _getRelevantSnapshot(): IQuotaSnapshot | undefined {
const quotas = this._chatEntitlementService.quotas;
const entitlement = this._chatEntitlementService.entitlement;
Expand Down Expand Up @@ -148,6 +222,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);
Expand Down Expand Up @@ -186,6 +266,103 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo
return undefined;
}

private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined {
if (!Language.isDefaultVariant() || !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;
}

const resetTime = new Date(resetDate).getTime();
if (!Number.isFinite(resetTime)) {
return undefined;
}

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_NUDGE_SPEC.minimumPercentUsed || percentUsed > TRAJECTORY_NUDGE_SPEC.maximumPercentUsed) {
return undefined;
}

const averageDailyUsage = percentUsed / elapsedDays;
if (averageDailyUsage < TRAJECTORY_NUDGE_SPEC.averageDailyUsageThreshold) {
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 === true ? { averageDailyUsage, percentUsed } : undefined;
}

private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void {
this._showingExhausted = false;
this._activeTrajectoryTelemetryData = this._getQuotaTrajectoryNudgeTelemetryData(warning);
this._storeTrajectoryShown();
const learnMoreLink = createMarkdownCommandLink({
text: localize('quota.trajectory.learnMore', "Learn more about managing credits"),
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: [TRAJECTORY_NUDGE_SPEC.learnMoreCommandId] } }),
description: undefined,
actions: [],
dismissible: true,
autoDismissOnMessage: false,
});
}

private async _handleCreditEfficiencyLearnMoreCommand(accessor: ServicesAccessor): Promise<void> {
if (this._activeTrajectoryTelemetryData) {
this._logQuotaTrajectoryNudgeLinkClicked(this._activeTrajectoryTelemetryData);
queueMicrotask(() => this._hideNotification());
}
await accessor.get(IOpenerService).open(URI.parse(TRAJECTORY_NUDGE_SPEC.learnMoreUrl));
}

private _logQuotaTrajectoryNudgeLinkClicked(data: ChatQuotaTrajectoryNudgeEvent): void {
this._telemetryService.publicLog2<ChatQuotaTrajectoryNudgeEvent, ChatQuotaTrajectoryNudgeClassification>('chatQuotaTrajectoryNudgeLinkClicked', data);
}

private _logQuotaTrajectoryNudgeEnrolled(treatment: boolean): void {
this._telemetryService.publicLog2<ChatQuotaTrajectoryNudgeEnrollmentEvent, ChatQuotaTrajectoryNudgeEnrollmentClassification>('chatQuotaTrajectoryNudgeEnrolled', {
treatment,
entitlement: ChatEntitlement[this._chatEntitlementService.entitlement],
});
}

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`.
*/
Expand All @@ -206,6 +383,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo

private _showExhaustedNotification(): void {
this._showingExhausted = true;
this._activeTrajectoryTelemetryData = undefined;

const entitlement = this._chatEntitlementService.entitlement;
const quotas = this._chatEntitlementService.quotas;
Expand Down Expand Up @@ -247,6 +425,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo

private _showOverageActivationNotification(): void {
this._showingExhausted = true;
this._activeTrajectoryTelemetryData = undefined;

this._setNotification({
id: QUOTA_NOTIFICATION_ID,
Expand All @@ -264,6 +443,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo

private _showQuotaApproachingWarning(warning: { percentUsed: number }): void {
this._showingExhausted = false;
this._activeTrajectoryTelemetryData = undefined;

const entitlement = this._chatEntitlementService.entitlement;
const quotas = this._chatEntitlementService.quotas;
Expand Down Expand Up @@ -336,6 +516,7 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo

private _showRateLimitWarning(warning: { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined }): void {
this._showingExhausted = false;
this._activeTrajectoryTelemetryData = undefined;

const message = warning.type === 'session'
? localize('rateLimit.session', "You've used {0}% of your session rate limit.", warning.percentUsed)
Expand Down Expand Up @@ -372,13 +553,19 @@ 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.Pro || entitlement === ChatEntitlement.ProPlus || entitlement === ChatEntitlement.Max;
}

private _isManagedPlanBlocked(): boolean {
const snapshot = this._chatEntitlementService.quotas.premiumChat;
return !!snapshot && snapshot.hasQuota === false;
}

private _showManagedPlanBlockedNotification(): void {
this._showingExhausted = true;
this._activeTrajectoryTelemetryData = undefined;

this._setNotification({
id: QUOTA_NOTIFICATION_ID,
Expand All @@ -402,12 +589,37 @@ 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 _isTrajectoryShownInCurrentPeriod(): boolean {
const periodKey = this._getTrajectoryPeriodKey();
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_NUDGE_SPEC.shownStorageKey, periodKey, StorageScope.APPLICATION, StorageTarget.USER);
}
}

private _setNotification(notification: IChatInputNotification): void {
this._chatInputNotificationService.setNotification(notification);
}

private _hideNotification(): void {
this._showingExhausted = false;
this._activeTrajectoryTelemetryData = undefined;
this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading