Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export class ChatInputNotificationContribution extends Disposable {
private _notification: vscode.ChatInputNotification | undefined;
/** Tracks whether the current notification is the quota-exhausted variant. */
private _showingExhausted = false;
/** Tracks the SKU of the last auth identity to detect sign-in/sign-out transitions. */
private _lastSku: string | undefined;
/**
* When true, the exhausted notification is suppressed until the quota
* transitions from non-exhausted → exhausted during this identity's
* session. Set after an identity change to avoid immediately re-showing
* a stale exhausted notification for the new identity.
*/
private _suppressExhaustedUntilTransition = false;

private readonly _shownQuotaThresholds = new Set<number>();
private readonly _shownSessionThresholds = new Set<number>();
Expand All @@ -47,23 +56,54 @@ export class ChatInputNotificationContribution extends Disposable {
@IChatQuotaService private readonly _chatQuotaService: IChatQuotaService,
) {
super();
this._register(this._authService.onDidAuthenticationChange(() => this._update()));
this._lastSku = this._authService.copilotToken?.sku;
this._register(this._authService.onDidAuthenticationChange(() => this._onAuthChanged()));
this._register(this._chatQuotaService.onDidChange(() => this._update()));
}

/**
* Handles auth changes. When the identity changes (e.g. sign-out from
* free → anonymous), hide any stale notification and reset shown
* thresholds so the new identity starts fresh.
*/
private _onAuthChanged(): void {
const currentSku = this._authService.copilotToken?.sku;
if (currentSku !== this._lastSku) {
this._lastSku = currentSku;
this._shownQuotaThresholds.clear();
this._shownSessionThresholds.clear();
this._shownWeeklyThresholds.clear();
this._suppressExhaustedUntilTransition = true;
this._hideNotification();
return;
}
this._update();
}

/**
* Single entry point that determines the highest-priority notification
* to show (or whether to hide).
*/
private _update(): void {
// Priority 1: Quota exhausted — sticky info notification
if (this._chatQuotaService.quotaExhausted) {
if (this._suppressExhaustedUntilTransition) {
// After an identity change, suppress the exhausted notification
// until the quota transitions from non-exhausted to exhausted
// during this identity's session. This prevents the stale
// exhausted state from immediately re-showing after sign-out.
return;
}
const isAnonymous = this._authService.copilotToken?.isNoAuthUser;
const isFree = this._authService.copilotToken?.isFreeUser;
if (isAnonymous || isFree) {
this._showExhaustedNotification(!!isAnonymous);
return;
}
} else {
// Quota is no longer exhausted — clear the suppression flag so
// that a future transition to exhausted will show the notification.
this._suppressExhaustedUntilTransition = false;
}

// Priority 2: Quota approaching threshold
Expand Down Expand Up @@ -236,6 +276,7 @@ export class ChatInputNotificationContribution extends Disposable {
private _hideNotification(): void {
if (this._notification) {
this._notification.hide();
this._showingExhausted = false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService {

private _processUserInfoQuotaSnapshot(quotaInfo: CopilotUserQuotaInfo | undefined) {
if (!quotaInfo || !quotaInfo.quota_snapshots || !quotaInfo.quota_reset_date) {
// Clear stale quota data when auth changes without quota info
// (e.g. user signed out). This ensures quota-exhausted
// notifications from the previous account don't persist.
if (this._quotaInfo) {
this._quotaInfo = undefined;
this._rateLimitInfo = { session: undefined, weekly: undefined };
this._onDidChange.fire();
}
return;
}
this._quotaInfo = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { Emitter, Event } from '../../../../util/vs/base/common/event';
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
import { IAuthenticationService } from '../../../authentication/common/authentication';
import { CopilotUserQuotaInfo } from '../../common/chatQuotaService';
import { ChatQuotaService } from '../../common/chatQuotaServiceImpl';

function makeQuotaInfo(overrides?: Partial<CopilotUserQuotaInfo>): CopilotUserQuotaInfo {
return {
quota_reset_date: '2026-06-01T00:00:00Z',
quota_snapshots: {
chat: { quota_id: 'chat', entitlement: 100, remaining: 0, unlimited: false, overage_count: 0, overage_permitted: false, percent_remaining: 0 },
completions: { quota_id: 'completions', entitlement: 100, remaining: 100, unlimited: false, overage_count: 0, overage_permitted: false, percent_remaining: 100 },
premium_interactions: { quota_id: 'premium', entitlement: 100, remaining: 0, unlimited: false, overage_count: 0, overage_permitted: false, percent_remaining: 0 },
},
...overrides,
};
}

class MockAuthenticationService {
declare readonly _serviceBrand: undefined;
private readonly _onDidAuthenticationChange = new Emitter<void>();
readonly onDidAuthenticationChange: Event<void> = this._onDidAuthenticationChange.event;
readonly onDidAccessTokenChange: Event<void> = Event.None;
readonly onDidAdoAuthenticationChange: Event<void> = Event.None;

copilotToken: { quotaInfo?: CopilotUserQuotaInfo; isFreeUser?: boolean; isNoAuthUser?: boolean } | undefined;

fireAuthChange(): void {
this._onDidAuthenticationChange.fire();
}

dispose(): void {
this._onDidAuthenticationChange.dispose();
}
}

describe('ChatQuotaService', () => {
let disposables: DisposableStore;
let authService: MockAuthenticationService;
let quotaService: ChatQuotaService;

beforeEach(() => {
disposables = new DisposableStore();
authService = new MockAuthenticationService();
disposables.add({ dispose: () => authService.dispose() });
quotaService = disposables.add(new ChatQuotaService(authService as unknown as IAuthenticationService));
});

test('starts with no quota info and not exhausted', () => {
expect(quotaService.quotaInfo).toBeUndefined();
expect(quotaService.quotaExhausted).toBe(false);
});

test('picks up quota info from auth change', () => {
const info = makeQuotaInfo();
authService.copilotToken = { quotaInfo: info, isFreeUser: true };
authService.fireAuthChange();

expect(quotaService.quotaInfo).toBeDefined();
expect(quotaService.quotaInfo!.percentRemaining).toBe(0);
expect(quotaService.quotaExhausted).toBe(true);
});

test('clears quota when auth changes to signed-out (no copilotToken)', () => {
// Set up exhausted state
authService.copilotToken = { quotaInfo: makeQuotaInfo(), isFreeUser: true };
authService.fireAuthChange();
expect(quotaService.quotaExhausted).toBe(true);

// Simulate sign-out: copilotToken becomes undefined
authService.copilotToken = undefined;
authService.fireAuthChange();

expect(quotaService.quotaInfo).toBeUndefined();
expect(quotaService.quotaExhausted).toBe(false);
expect(quotaService.rateLimitInfo.session).toBeUndefined();
expect(quotaService.rateLimitInfo.weekly).toBeUndefined();
});

test('fires onDidChange when quota is cleared on sign-out', () => {
// Set up exhausted state
authService.copilotToken = { quotaInfo: makeQuotaInfo(), isFreeUser: true };
authService.fireAuthChange();

const onChange = vi.fn();
disposables.add(quotaService.onDidChange(onChange));

// Sign out
authService.copilotToken = undefined;
authService.fireAuthChange();

expect(onChange).toHaveBeenCalled();
});

test('does not fire onDidChange when sign-out occurs with no prior quota', () => {
// No quota set initially
const onChange = vi.fn();
disposables.add(quotaService.onDidChange(onChange));

authService.copilotToken = undefined;
authService.fireAuthChange();

expect(onChange).not.toHaveBeenCalled();
});

test('clears quota when auth changes to token without quotaInfo', () => {
// Set up exhausted state
authService.copilotToken = { quotaInfo: makeQuotaInfo(), isFreeUser: true };
authService.fireAuthChange();
expect(quotaService.quotaExhausted).toBe(true);

// Sign in with different account that has no quotaInfo yet
authService.copilotToken = { isFreeUser: false };
authService.fireAuthChange();

expect(quotaService.quotaInfo).toBeUndefined();
expect(quotaService.quotaExhausted).toBe(false);
});

test('quota is not exhausted when percentRemaining > 0', () => {
const info = makeQuotaInfo();
info.quota_snapshots!.premium_interactions.percent_remaining = 50;
authService.copilotToken = { quotaInfo: info, isFreeUser: true };
authService.fireAuthChange();

expect(quotaService.quotaExhausted).toBe(false);
});

test('quota is not exhausted when additional usage is enabled', () => {
const info = makeQuotaInfo();
info.quota_snapshots!.premium_interactions.overage_permitted = true;
authService.copilotToken = { quotaInfo: info, isFreeUser: true };
authService.fireAuthChange();

expect(quotaService.quotaExhausted).toBe(false);
});

test('quota is not exhausted when unlimited', () => {
const info = makeQuotaInfo();
info.quota_snapshots!.premium_interactions.unlimited = true;
info.quota_snapshots!.premium_interactions.entitlement = -1;
authService.copilotToken = { quotaInfo: info, isFreeUser: true };
authService.fireAuthChange();

expect(quotaService.quotaExhausted).toBe(false);
});

afterEach(() => {
disposables.dispose();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu
let text = '$(copilot)';
let ariaLabel = localize('chatStatusAria', "Copilot status");
let kind: StatusbarEntryKind | undefined;
let triggerSetup = false;

if (isNewUser(this.chatEntitlementService)) {
const entitlement = this.chatEntitlementService.entitlement;
Expand All @@ -153,6 +154,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu

text = `$(copilot) ${signIn}`;
ariaLabel = signIn;
triggerSetup = true;
}
} else {
const chatQuotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0;
Expand All @@ -179,6 +181,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu
const signIn = localize('signIn', "Sign In");
text = `$(copilot) ${signIn}`;
ariaLabel = signIn;
triggerSetup = true;
}

// Free Quota Exceeded
Expand Down Expand Up @@ -210,6 +213,19 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu
}
}

// When user needs to sign in, clicking the status bar entry
// directly triggers setup instead of showing the dashboard
if (triggerSetup) {
return {
name: localize('chatStatus', "Copilot Status"),
text,
ariaLabel,
command: 'workbench.action.chat.triggerSetup',
showInAllWindows: true,
kind,
};
}

const baseResult = {
name: localize('chatStatus', "Copilot Status"),
text,
Expand Down
Loading