mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-20 16:23:02 +01:00
Remove fragile chat notification model switching (#325299)
* Remove fragile model switching * Cleanup unneeded model lookup
This commit is contained in:
@@ -5,25 +5,14 @@
|
||||
|
||||
import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';
|
||||
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
|
||||
import { IWorkbenchContribution } from '../../../common/contributions.js';
|
||||
import { localChatSessionType } from '../common/chatSessionsService.js';
|
||||
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/languageModels.js';
|
||||
import { IChatWidgetService } from './chat.js';
|
||||
import { ChatInputNotificationSeverity, IChatInputNotificationService } from './widget/input/chatInputNotificationService.js';
|
||||
|
||||
const PROMO_NOTIFICATION_ID = 'copilot.promoNotification';
|
||||
const DISMISSED_PROMOS_STORAGE_KEY = 'chat.dismissedPromoIds';
|
||||
const USE_PROMO_MODEL_COMMAND_ID = 'workbench.action.chat.usePromoModel';
|
||||
|
||||
interface IUsePromoModelArgs {
|
||||
/** Identifier of the model to switch to. */
|
||||
readonly modelIdentifier: string;
|
||||
/** Notification to dismiss once the model has been selected. */
|
||||
readonly notificationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches for models with active promotions and surfaces a chat input
|
||||
@@ -44,14 +33,6 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(CommandsRegistry.registerCommand(USE_PROMO_MODEL_COMMAND_ID, (accessor: ServicesAccessor, args: IUsePromoModelArgs) => {
|
||||
const chatWidgetService = accessor.get(IChatWidgetService);
|
||||
const widget = chatWidgetService.lastFocusedWidget;
|
||||
widget?.input.switchModelByIdentifier(args.modelIdentifier);
|
||||
// Dismissing fires `onDidDismiss`, which persists this promo so it isn't shown again.
|
||||
this._chatInputNotificationService.dismissNotification(args.notificationId);
|
||||
}));
|
||||
|
||||
this._register(this._languageModelsService.onDidChangeLanguageModels(() => this._update()));
|
||||
this._register(this._chatInputNotificationService.onDidDismiss(id => {
|
||||
const promoId = this._shownNotifications.get(id);
|
||||
@@ -71,28 +52,25 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
|
||||
const modelIds = this._languageModelsService.getLanguageModelIds();
|
||||
|
||||
// A promo can appear in several harnesses at once (e.g. the same model
|
||||
// offered in the Local, Copilot, and Codex sessions). Each harness has its
|
||||
// own model copy, so the notification must advertise the model that belongs
|
||||
// to the harness the chat input is actually in — otherwise the "Use <model>"
|
||||
// action would switch to a model that isn't valid for that session. Bucket
|
||||
// the first non-dismissed promo per harness (a model's `targetChatSessionType`,
|
||||
// offered in the Local, Copilot, and Codex sessions). Bucket the first
|
||||
// non-dismissed promo per harness (a model's `targetChatSessionType`,
|
||||
// or the local pool when unset).
|
||||
const promoByHarness = new Map<string, { readonly promo: NonNullable<ILanguageModelChatMetadata['promo']>; readonly name: string; readonly identifier: string }>();
|
||||
const promoByHarness = new Map<string, NonNullable<ILanguageModelChatMetadata['promo']>>();
|
||||
for (const id of modelIds) {
|
||||
const meta = this._languageModelsService.lookupLanguageModel(id);
|
||||
if (!meta?.promo || dismissed.has(meta.promo.id)) {
|
||||
if (!meta || !ILanguageModelChatMetadata.hasPromoDiscount(meta) || dismissed.has(meta.promo.id)) {
|
||||
continue;
|
||||
}
|
||||
const harness = meta.targetChatSessionType ?? localChatSessionType;
|
||||
if (!promoByHarness.has(harness)) {
|
||||
promoByHarness.set(harness, { promo: meta.promo, name: meta.name, identifier: id });
|
||||
promoByHarness.set(harness, meta.promo);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the notification for every harness that has an eligible promo,
|
||||
// scoping each one to its harness so it only renders in matching sessions.
|
||||
const desired = new Set<string>();
|
||||
for (const [harness, { promo, name, identifier }] of promoByHarness) {
|
||||
for (const [harness, promo] of promoByHarness) {
|
||||
const notificationId = `${PROMO_NOTIFICATION_ID}.${harness}`;
|
||||
desired.add(notificationId);
|
||||
|
||||
@@ -111,11 +89,7 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
|
||||
severity: ChatInputNotificationSeverity.Info,
|
||||
message: promo.message,
|
||||
description: localize('chat.promo.endsAt', "Ends {0}.", formattedDate),
|
||||
actions: [{
|
||||
label: localize('chat.promo.useModel', "Use {0}", name),
|
||||
commandId: USE_PROMO_MODEL_COMMAND_ID,
|
||||
commandArgs: [{ modelIdentifier: identifier, notificationId } satisfies IUsePromoModelArgs],
|
||||
}],
|
||||
actions: [],
|
||||
dismissible: true,
|
||||
autoDismissOnMessage: false,
|
||||
sessionTypes: [harness],
|
||||
|
||||
+32
-27
@@ -95,6 +95,15 @@ export interface IChatInputNotificationService {
|
||||
* that have {@link IChatInputNotification.autoDismissOnMessage} set.
|
||||
*/
|
||||
handleMessageSent(): void;
|
||||
|
||||
/**
|
||||
* Announce a notification that a chat input is about to render to screen
|
||||
* readers. De-duplicated per notification id across all mounted chat inputs,
|
||||
* so content shown in several widgets (panel, side bar, …) is only spoken
|
||||
* once and session-scoped notifications are only announced when a chat input
|
||||
* in a matching session actually renders them. Passing `undefined` is a no-op.
|
||||
*/
|
||||
announceRendered(notification: IChatInputNotification | undefined): void;
|
||||
}
|
||||
|
||||
class ChatInputNotificationService extends Disposable implements IChatInputNotificationService {
|
||||
@@ -114,11 +123,12 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
|
||||
readonly onDidDismiss = this._onDidDismiss.event;
|
||||
|
||||
/**
|
||||
* Signature of the last active notification we announced via ARIA, so we
|
||||
* don't re-announce the same content when the model fires `onDidChange`
|
||||
* for unrelated reasons or when the same notification is re-pushed.
|
||||
* Last ARIA-announced signature per notification id. Lets us skip
|
||||
* re-announcing unchanged content (e.g. a notification re-pushed on every
|
||||
* quota tick, or the same notification rendered by several mounted chat
|
||||
* inputs) while still announcing when a notification's content changes.
|
||||
*/
|
||||
private _lastAnnouncedSignature: string | undefined;
|
||||
private readonly _announcedById = new Map<string, string>();
|
||||
|
||||
setNotification(notification: IChatInputNotification): void {
|
||||
this._notifications.set(notification.id, notification);
|
||||
@@ -131,6 +141,7 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
|
||||
if (this._notifications.delete(id)) {
|
||||
this._dismissed.delete(id);
|
||||
this._insertionOrder.delete(id);
|
||||
this._announcedById.delete(id);
|
||||
this._fireDidChange();
|
||||
}
|
||||
}
|
||||
@@ -138,6 +149,8 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
|
||||
dismissNotification(id: string): void {
|
||||
if (this._notifications.has(id) && !this._dismissed.has(id)) {
|
||||
this._dismissed.add(id);
|
||||
// Forget the announced signature so a later re-show is announced again.
|
||||
this._announcedById.delete(id);
|
||||
this._onDidDismiss.fire(id);
|
||||
this._fireDidChange();
|
||||
}
|
||||
@@ -183,37 +196,29 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
|
||||
}
|
||||
|
||||
private _fireDidChange(): void {
|
||||
this._announceActiveIfChanged();
|
||||
this._onDidChange.fire();
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce the currently active notification to screen readers, but only
|
||||
* when its content differs from the last announced one. This prevents
|
||||
* the same notification from being announced repeatedly when:
|
||||
* - the same notification is re-pushed by an extension (e.g. on every
|
||||
* quota change tick),
|
||||
* - multiple chat widgets are mounted (panel, side bar, etc.) — the
|
||||
* announcement happens once at the singleton level instead of once
|
||||
* per widget.
|
||||
*/
|
||||
private _announceActiveIfChanged(): void {
|
||||
const active = this.getActiveNotification();
|
||||
if (!active) {
|
||||
this._lastAnnouncedSignature = undefined;
|
||||
announceRendered(notification: IChatInputNotification | undefined): void {
|
||||
// Announcements are driven from the chat input's render path (rather than
|
||||
// eagerly on every change) so that session-scoped notifications are only
|
||||
// spoken when a chat input in a matching session actually shows them. The
|
||||
// service still owns the de-dupe state so the same content isn't announced
|
||||
// once per mounted chat input (panel, side bar, …).
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
const rawMessage = typeof active.message === 'string' ? active.message : active.message.value;
|
||||
const signature = `${active.id}\u0000${rawMessage}\u0000${active.description ?? ''}`;
|
||||
if (signature === this._lastAnnouncedSignature) {
|
||||
const rawMessage = typeof notification.message === 'string' ? notification.message : notification.message.value;
|
||||
const signature = `${notification.id}\u0000${rawMessage}\u0000${notification.description ?? ''}`;
|
||||
if (this._announcedById.get(notification.id) === signature) {
|
||||
return;
|
||||
}
|
||||
this._lastAnnouncedSignature = signature;
|
||||
this._announcedById.set(notification.id, signature);
|
||||
// Strip Markdown syntax so screen readers don't read backticks, link
|
||||
// targets, etc. verbatim. Done after the signature check so we don't
|
||||
// pay the parse cost on unrelated `onDidChange` fires.
|
||||
const message = renderAsPlaintext(active.message);
|
||||
const text = active.description ? `${message}. ${active.description}` : message;
|
||||
// targets, etc. verbatim. Done after the de-dupe check so we don't pay
|
||||
// the parse cost on unrelated re-renders.
|
||||
const message = renderAsPlaintext(notification.message);
|
||||
const text = notification.description ? `${message}. ${notification.description}` : message;
|
||||
status(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ export class ChatInputNotificationWidget extends Disposable {
|
||||
dom.clearNode(this.domNode);
|
||||
|
||||
const notification = this._notificationService.getActiveNotification(n => this._matchesSession(n));
|
||||
// Announce what this chat input actually renders, so session-scoped
|
||||
// notifications are only spoken in a matching session (de-duped by the service).
|
||||
this._notificationService.announceRendered(notification);
|
||||
if (!notification) {
|
||||
this.domNode.parentElement?.classList.remove('has-notification');
|
||||
this._lastShownTelemetryData = undefined;
|
||||
|
||||
@@ -34,7 +34,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
|
||||
import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js';
|
||||
import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js';
|
||||
import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelsControlManifest } from '../../../common/languageModels.js';
|
||||
import { IModelControlEntry, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelsControlManifest } from '../../../common/languageModels.js';
|
||||
import { ChatEntitlement, chatRequiresSetup, IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js';
|
||||
import * as semver from '../../../../../../base/common/semver/semver.js';
|
||||
import { IModelConfigurationAccess, IModelPickerDelegate } from './modelPickerActionItem.js';
|
||||
@@ -393,7 +393,8 @@ function createModelAction(
|
||||
// Strip the detail when suppressVendorInDetail is set — the vendor is
|
||||
// shown either inline (promoted) or in a section header (Other Models).
|
||||
const detail = suppressVendorInDetail ? undefined : model.metadata.detail;
|
||||
const promoDetail = model.metadata.promo ? localize('chat.promo.discount', "{0}% discount", model.metadata.promo.discountPercent) : undefined;
|
||||
const promo = ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined;
|
||||
const promoDetail = promo ? localize('chat.promo.discount', "{0}% discount", promo.discountPercent) : undefined;
|
||||
const textParts = [detail, promoDetail, pricingForDescription].filter(Boolean);
|
||||
const textDescription = textParts.length > 0 ? textParts.join(' · ') : undefined;
|
||||
|
||||
@@ -651,12 +652,12 @@ export function buildModelPickerItems(
|
||||
items.push(createModelItem(autoAction, autoModel, openerService, undefined, isUBB, autoAriaDesc));
|
||||
}
|
||||
|
||||
// --- 1b. Promo models (boosted next to Auto) ---
|
||||
// --- 1b. Discounted promo models (boosted next to Auto) ---
|
||||
for (const model of models) {
|
||||
if (placed.has(model.identifier) || placed.has(model.metadata.id)) {
|
||||
continue;
|
||||
}
|
||||
if (model.metadata.promo) {
|
||||
if (ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) {
|
||||
markPlaced(model.identifier, model.metadata.id);
|
||||
const { action: promoAction, ariaDescription: promoAriaDesc } = createModelAction(model, selectedModelId, onSelect);
|
||||
items.push(createModelItem(promoAction, model, openerService, undefined, isUBB, promoAriaDesc));
|
||||
@@ -747,6 +748,15 @@ export function buildModelPickerItems(
|
||||
tryPlaceModel(id);
|
||||
}
|
||||
|
||||
// Non-discount promos are featured without promotional presentation.
|
||||
if (showFeatured) {
|
||||
for (const model of models) {
|
||||
if (model.metadata.promo && !ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) {
|
||||
tryPlaceModel(model.identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Featured models from control manifest
|
||||
if (showFeatured) {
|
||||
for (const [entryId, entry] of Object.entries(controlModels)) {
|
||||
@@ -1821,6 +1831,7 @@ const SUPPORTED_CONFIG_GROUPS: readonly string[] = ['navigation', 'tokens'];
|
||||
|
||||
export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, isUBB: boolean | undefined, onConfigure: ((group: string) => void) | undefined, openerService: IOpenerService): { element: HTMLElement; disposable: DisposableStore } | undefined {
|
||||
const isAuto = isAutoModel(model);
|
||||
const promo = !isAuto && ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined;
|
||||
const container = dom.$('.chat-model-hover');
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
@@ -1828,7 +1839,7 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif
|
||||
const titleRow = dom.$('.chat-model-hover-title-row');
|
||||
titleRow.appendChild(dom.$('.chat-model-hover-name', undefined, model.metadata.name));
|
||||
const tags = dom.$('.chat-model-hover-title-tags');
|
||||
const categoryLabel = !isAuto && !model.metadata.promo ? getCategoryLabel(model.metadata.category) : undefined;
|
||||
const categoryLabel = !isAuto && !promo ? getCategoryLabel(model.metadata.category) : undefined;
|
||||
if (categoryLabel) {
|
||||
tags.appendChild(dom.$('span.chat-model-hover-category', undefined, categoryLabel));
|
||||
}
|
||||
@@ -1842,8 +1853,8 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif
|
||||
tags.appendChild(badge);
|
||||
}
|
||||
// When a model carries a promo discount, show a discount pill alongside the price category.
|
||||
if (!isAuto && model.metadata.promo) {
|
||||
const discountLabel = localize('chat.promo.discountBadge', "{0}% discount", model.metadata.promo.discountPercent);
|
||||
if (promo) {
|
||||
const discountLabel = localize('chat.promo.discountBadge', "{0}% discount", promo.discountPercent);
|
||||
tags.appendChild(dom.$('span.chat-model-hover-price-badge', undefined, discountLabel));
|
||||
}
|
||||
if (tags.childElementCount > 0) {
|
||||
@@ -1867,12 +1878,12 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif
|
||||
}
|
||||
|
||||
// --- Promo info ---
|
||||
if (!isAuto && model.metadata.promo) {
|
||||
if (promo) {
|
||||
const promoContainer = dom.$('.chat-model-hover-promo-text');
|
||||
promoContainer.appendChild(renderIcon(Codicon.info));
|
||||
const endsAtDate = new Date(model.metadata.promo.endsAt);
|
||||
const endsAtDate = new Date(promo.endsAt);
|
||||
const formattedDate = endsAtDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const promoMessage = model.metadata.promo.message + ' ' + localize('chat.promo.endsAt', "Ends {0}.", formattedDate);
|
||||
const promoMessage = promo.message + ' ' + localize('chat.promo.endsAt', "Ends {0}.", formattedDate);
|
||||
const promoMd = new MarkdownString(promoMessage, { isTrusted: false, supportThemeIcons: true });
|
||||
const rendered = disposables.add(renderMarkdown(promoMd, {
|
||||
actionHandler: (link: string) => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); },
|
||||
|
||||
@@ -313,8 +313,8 @@ export interface ILanguageModelChatMetadata {
|
||||
*/
|
||||
readonly warningText?: IStringDictionary<string>;
|
||||
/**
|
||||
* Optional promotional information for this model.
|
||||
* When present, indicates the model is experiencing a promotional discount.
|
||||
* Optional promotional information for this model. Positive discounts surface
|
||||
* promotional UI; non-positive discounts only feature the model in the picker.
|
||||
*/
|
||||
readonly promo?: {
|
||||
readonly id: string;
|
||||
@@ -341,6 +341,10 @@ export namespace ILanguageModelChatMetadata {
|
||||
return name === asQualifiedName(metadata);
|
||||
}
|
||||
|
||||
export function hasPromoDiscount(metadata: ILanguageModelChatMetadata): metadata is ILanguageModelChatMetadata & { readonly promo: NonNullable<ILanguageModelChatMetadata['promo']> } {
|
||||
return !!metadata.promo && metadata.promo.discountPercent > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Documentation link explaining how Auto model selection works.
|
||||
* NOTE: Also defined in extensions/copilot/src/extension/conversation/common/languageModelAccess.ts — keep in sync.
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ class FakeNotificationService implements IChatInputNotificationService {
|
||||
dismissNotification(_id: string): void { /* */ }
|
||||
getActiveNotification(): IChatInputNotification | undefined { return undefined; }
|
||||
handleMessageSent(): void { /* */ }
|
||||
announceRendered(): void { /* */ }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,7 @@ function createMockNotificationService(disposables: Pick<DisposableStore, 'add'>
|
||||
return active;
|
||||
},
|
||||
handleMessageSent() { },
|
||||
announceRendered() { },
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -125,8 +126,30 @@ suite('ChatPromoNotificationContribution', () => {
|
||||
const notification = notifService.getNotification();
|
||||
assert.ok(notification, 'Expected a notification to be shown');
|
||||
assert.ok(notification.message.toString().includes('20% off'));
|
||||
assert.strictEqual(notification.actions.length, 1);
|
||||
assert.ok(notification.actions[0].label.includes('GPT-5.5'));
|
||||
assert.strictEqual(notification.actions.length, 0);
|
||||
});
|
||||
|
||||
test('does not show notification for non-positive promo discounts', () => {
|
||||
const notifService = createMockNotificationService(disposables);
|
||||
const { service: lmService } = createMockLanguageModelsService([
|
||||
{
|
||||
identifier: 'copilot:zero-discount',
|
||||
metadata: { name: 'Zero Discount', id: 'zero-discount', promo: { id: 'promo-zero', discountPercent: 0, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
|
||||
},
|
||||
{
|
||||
identifier: 'copilot:negative-discount',
|
||||
metadata: { name: 'Negative Discount', id: 'negative-discount', promo: { id: 'promo-negative', discountPercent: -10, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
|
||||
},
|
||||
], disposables);
|
||||
const storageService = disposables.add(new InMemoryStorageService());
|
||||
|
||||
disposables.add(new ChatPromoNotificationContribution(
|
||||
lmService,
|
||||
notifService.service,
|
||||
storageService,
|
||||
));
|
||||
|
||||
assert.strictEqual(notifService.getNotification(), undefined);
|
||||
});
|
||||
|
||||
test('does not show notification for already-dismissed promo', () => {
|
||||
@@ -280,22 +303,21 @@ suite('ChatPromoNotificationContribution', () => {
|
||||
// One notification per harness.
|
||||
assert.strictEqual(notifService.getAllNotifications().length, 3);
|
||||
|
||||
// Each session only sees the promo for the model that belongs to it, and
|
||||
// the "Use <model>" action switches to that harness's model.
|
||||
// Each session only sees the promo for the model that belongs to it.
|
||||
const local = notifService.getNotificationForSession('local');
|
||||
assert.ok(local, 'Expected a local promo');
|
||||
assert.ok(local.message.toString().includes('Local promo'));
|
||||
assert.deepStrictEqual(local.actions[0].commandArgs, [{ modelIdentifier: 'local:gpt-5.5', notificationId: local.id }]);
|
||||
assert.strictEqual(local.actions.length, 0);
|
||||
|
||||
const copilot = notifService.getNotificationForSession('copilotcli');
|
||||
assert.ok(copilot, 'Expected a Copilot promo');
|
||||
assert.ok(copilot.message.toString().includes('Copilot promo'));
|
||||
assert.deepStrictEqual(copilot.actions[0].commandArgs, [{ modelIdentifier: 'copilot:claude', notificationId: copilot.id }]);
|
||||
assert.strictEqual(copilot.actions.length, 0);
|
||||
|
||||
const codex = notifService.getNotificationForSession('openai-codex');
|
||||
assert.ok(codex, 'Expected a Codex promo');
|
||||
assert.ok(codex.message.toString().includes('Codex promo'));
|
||||
assert.deepStrictEqual(codex.actions[0].commandArgs, [{ modelIdentifier: 'codex:o4', notificationId: codex.id }]);
|
||||
assert.strictEqual(codex.actions.length, 0);
|
||||
});
|
||||
|
||||
test('does not leak a harness promo into a different session type', () => {
|
||||
|
||||
@@ -133,6 +133,7 @@ function createMockNotificationService() {
|
||||
return !filter || filter(lastNotification) ? lastNotification : undefined;
|
||||
},
|
||||
handleMessageSent() { },
|
||||
announceRendered() { },
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+128
-3
@@ -4,7 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { Event } from '../../../../../../../base/common/event.js';
|
||||
import { Emitter, Event } from '../../../../../../../base/common/event.js';
|
||||
import { IDisposable } from '../../../../../../../base/common/lifecycle.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
|
||||
import { ICommandEvent, ICommandService } from '../../../../../../../platform/commands/common/commands.js';
|
||||
@@ -14,7 +14,7 @@ import { ServiceCollection } from '../../../../../../../platform/instantiation/c
|
||||
import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js';
|
||||
import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js';
|
||||
import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
|
||||
import { ChatInputNotificationSeverity, IChatInputNotificationService } from '../../../../browser/widget/input/chatInputNotificationService.js';
|
||||
import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../../../browser/widget/input/chatInputNotificationService.js';
|
||||
import { ChatInputNotificationWidget } from '../../../../browser/widget/input/chatInputNotificationWidget.js';
|
||||
import { localChatSessionType, SessionType } from '../../../../common/chatSessionsService.js';
|
||||
|
||||
@@ -24,7 +24,10 @@ class TestCommandService implements ICommandService {
|
||||
readonly onWillExecuteCommand: Event<ICommandEvent> = Event.None;
|
||||
readonly onDidExecuteCommand: Event<ICommandEvent> = Event.None;
|
||||
|
||||
async executeCommand(): Promise<undefined> {
|
||||
readonly executed: { readonly id: string; readonly args: readonly unknown[] }[] = [];
|
||||
|
||||
async executeCommand(id: string, ...args: unknown[]): Promise<undefined> {
|
||||
this.executed.push({ id, args });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -78,4 +81,126 @@ suite('ChatInputNotificationWidget', () => {
|
||||
widget.rerender();
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification')?.textContent, 'Local only');
|
||||
});
|
||||
|
||||
/**
|
||||
* A notification service mock that records the notifications forwarded to
|
||||
* {@link IChatInputNotificationService.announceRendered} and applies the
|
||||
* `getActiveNotification` filter, so tests can observe exactly what a chat
|
||||
* input would render and announce for its session.
|
||||
*/
|
||||
function createRecordingNotificationService() {
|
||||
const notifications = new Map<string, IChatInputNotification>();
|
||||
const announced: (IChatInputNotification | undefined)[] = [];
|
||||
const onDidChange = store.add(new Emitter<void>());
|
||||
const service: IChatInputNotificationService = {
|
||||
_serviceBrand: undefined,
|
||||
onDidChange: onDidChange.event,
|
||||
onDidDismiss: Event.None,
|
||||
setNotification(notification) { notifications.set(notification.id, notification); onDidChange.fire(); },
|
||||
deleteNotification(id) { if (notifications.delete(id)) { onDidChange.fire(); } },
|
||||
dismissNotification() { },
|
||||
getActiveNotification(filter) {
|
||||
let active: IChatInputNotification | undefined;
|
||||
for (const notification of notifications.values()) {
|
||||
if (filter && !filter(notification)) {
|
||||
continue;
|
||||
}
|
||||
active = notification;
|
||||
}
|
||||
return active;
|
||||
},
|
||||
handleMessageSent() { },
|
||||
announceRendered(notification) { announced.push(notification); },
|
||||
};
|
||||
return { service, announced, set: (notification: IChatInputNotification) => service.setNotification(notification) };
|
||||
}
|
||||
|
||||
test('action commands execute with provided args', async () => {
|
||||
const commandService = new TestCommandService();
|
||||
const notificationService = createRecordingNotificationService();
|
||||
|
||||
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
|
||||
instantiationService.stub(IChatInputNotificationService, notificationService.service);
|
||||
instantiationService.stub(ICommandService, commandService);
|
||||
instantiationService.stub(ITelemetryService, NullTelemetryService);
|
||||
|
||||
const widget = store.add(instantiationService.createInstance(ChatInputNotificationWidget, () => localChatSessionType));
|
||||
|
||||
notificationService.set({
|
||||
id: 'promo',
|
||||
severity: ChatInputNotificationSeverity.Info,
|
||||
message: 'Promo',
|
||||
description: undefined,
|
||||
actions: [{ label: 'Use', commandId: 'test.usePromo', commandArgs: [{ modelIdentifier: 'm' }] }],
|
||||
dismissible: true,
|
||||
autoDismissOnMessage: false,
|
||||
});
|
||||
|
||||
const button = widget.domNode.querySelector<HTMLElement>('.chat-input-notification-action-button');
|
||||
assert.ok(button);
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepStrictEqual(commandService.executed, [{ id: 'test.usePromo', args: [{ modelIdentifier: 'm' }] }]);
|
||||
});
|
||||
|
||||
test('actions without explicit commandArgs are executed with empty args', async () => {
|
||||
const commandService = new TestCommandService();
|
||||
const notificationService = createRecordingNotificationService();
|
||||
|
||||
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
|
||||
instantiationService.stub(IChatInputNotificationService, notificationService.service);
|
||||
instantiationService.stub(ICommandService, commandService);
|
||||
instantiationService.stub(ITelemetryService, NullTelemetryService);
|
||||
|
||||
const widget = store.add(instantiationService.createInstance(ChatInputNotificationWidget, () => localChatSessionType));
|
||||
|
||||
notificationService.set({
|
||||
id: 'info',
|
||||
severity: ChatInputNotificationSeverity.Info,
|
||||
message: 'Info',
|
||||
description: undefined,
|
||||
actions: [{ label: 'Upgrade', commandId: 'test.upgrade' }],
|
||||
dismissible: true,
|
||||
autoDismissOnMessage: false,
|
||||
});
|
||||
|
||||
const button = widget.domNode.querySelector<HTMLElement>('.chat-input-notification-action-button');
|
||||
assert.ok(button);
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepStrictEqual(commandService.executed, [{ id: 'test.upgrade', args: [] }]);
|
||||
});
|
||||
|
||||
test('announces only the notification rendered in the current session', () => {
|
||||
let currentSessionType = localChatSessionType;
|
||||
const notificationService = createRecordingNotificationService();
|
||||
|
||||
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
|
||||
instantiationService.stub(IChatInputNotificationService, notificationService.service);
|
||||
instantiationService.stub(ICommandService, new TestCommandService());
|
||||
instantiationService.stub(ITelemetryService, NullTelemetryService);
|
||||
|
||||
const widget = store.add(instantiationService.createInstance(ChatInputNotificationWidget, () => currentSessionType));
|
||||
const lastAnnounced = () => notificationService.announced[notificationService.announced.length - 1];
|
||||
|
||||
// A promo scoped to the Copilot harness must not be announced while the
|
||||
// input is in the local session.
|
||||
notificationService.set({
|
||||
id: 'copilot-promo',
|
||||
severity: ChatInputNotificationSeverity.Info,
|
||||
message: 'Copilot promo',
|
||||
description: undefined,
|
||||
actions: [],
|
||||
dismissible: true,
|
||||
autoDismissOnMessage: false,
|
||||
sessionTypes: [SessionType.AgentHostCopilot],
|
||||
});
|
||||
assert.strictEqual(lastAnnounced(), undefined, 'nothing should be announced in a non-matching session');
|
||||
|
||||
currentSessionType = SessionType.AgentHostCopilot;
|
||||
widget.rerender();
|
||||
assert.strictEqual(lastAnnounced()?.id, 'copilot-promo', 'the promo should be announced once its session is active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,9 @@ import { IStringDictionary } from '../../../../../../../base/common/collections.
|
||||
import { MarkdownString } from '../../../../../../../base/common/htmlContent.js';
|
||||
import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js';
|
||||
import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js';
|
||||
import { NullOpenerService } from '../../../../../../../platform/opener/test/common/nullOpenerService.js';
|
||||
import { StateType } from '../../../../../../../platform/update/common/update.js';
|
||||
import { buildModelPickerItems, getControlModelsForEntitlement, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js';
|
||||
import { buildModelPickerItems, getControlModelsForEntitlement, getModelHoverContent, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js';
|
||||
import { getModelProviderIcon } from '../../../../browser/widget/input/modelProviderIcons.js';
|
||||
import { filterModelsForSession } from '../../../../browser/widget/input/chatModelSelectionLogic.js';
|
||||
import { ChatAgentLocation, ChatModeKind } from '../../../../common/constants.js';
|
||||
@@ -217,7 +218,7 @@ function createControlManifest(): IModelsControlManifest {
|
||||
|
||||
suite('buildModelPickerItems', () => {
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('accessibility provider uses radio semantics for model items', () => {
|
||||
const provider = getModelPickerAccessibilityProvider();
|
||||
@@ -754,6 +755,55 @@ suite('buildModelPickerItems', () => {
|
||||
assert.strictEqual(allGemini.length, 1, 'Promo model should appear exactly once');
|
||||
});
|
||||
|
||||
test('non-positive promo models are featured without discount details', () => {
|
||||
const auto = createAutoModel();
|
||||
const zeroDiscountModel = createModel('zero-discount', 'Zero Discount');
|
||||
zeroDiscountModel.metadata = { ...zeroDiscountModel.metadata, promo: { id: 'test-promo-zero', discountPercent: 0, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } } as ILanguageModelChatMetadata;
|
||||
const negativeDiscountModel = createModel('negative-discount', 'Negative Discount');
|
||||
negativeDiscountModel.metadata = { ...negativeDiscountModel.metadata, promo: { id: 'test-promo-negative', discountPercent: -10, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } } as ILanguageModelChatMetadata;
|
||||
const manifestFeaturedModel = createModel('manifest-featured', 'Manifest Featured');
|
||||
const items = callBuild([auto, zeroDiscountModel, negativeDiscountModel, manifestFeaturedModel], {
|
||||
controlModels: {
|
||||
'manifest-featured': { label: 'Manifest Featured', featured: true, exists: true },
|
||||
},
|
||||
});
|
||||
const labels = new Set(['Auto', 'Zero Discount', 'Negative Discount', 'Manifest Featured']);
|
||||
const featuredItems = getActionItems(items).filter(item => labels.has(item.label!));
|
||||
|
||||
assert.deepStrictEqual(featuredItems.map(item => ({ label: item.label, description: item.description })), [
|
||||
{ label: 'Auto', description: undefined },
|
||||
{ label: 'Manifest Featured', description: undefined },
|
||||
{ label: 'Negative Discount', description: undefined },
|
||||
{ label: 'Zero Discount', description: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test('non-positive promo models have no promo hover presentation', () => {
|
||||
const results = [0, -10].map(discountPercent => {
|
||||
const model = createModel(`discount-${discountPercent}`, `Discount ${discountPercent}`);
|
||||
model.metadata = {
|
||||
...model.metadata,
|
||||
category: 'powerful',
|
||||
priceCategory: 'high',
|
||||
promo: { id: `test-promo-${discountPercent}`, discountPercent, endsAt: '2026-07-20T23:59:59Z', message: 'Do not render this text' },
|
||||
} as ILanguageModelChatMetadata;
|
||||
const hover = getModelHoverContent(model, false, undefined, NullOpenerService);
|
||||
assert.ok(hover);
|
||||
disposables.add(hover.disposable);
|
||||
return {
|
||||
discountPercent,
|
||||
category: hover.element.querySelector('.chat-model-hover-category')?.textContent,
|
||||
badges: Array.from(hover.element.querySelectorAll('.chat-model-hover-price-badge'), element => element.textContent),
|
||||
promoText: hover.element.querySelector('.chat-model-hover-promo-text')?.textContent,
|
||||
};
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(results, [
|
||||
{ discountPercent: 0, category: 'Powerful', badges: ['High cost'], promoText: undefined },
|
||||
{ discountPercent: -10, category: 'Powerful', badges: ['High cost'], promoText: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test('Other Models grouped by vendor with separator headers', () => {
|
||||
const auto = createAutoModel();
|
||||
const modelA = createModel('zebra', 'Zebra', 'copilot');
|
||||
|
||||
@@ -238,6 +238,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I
|
||||
reg.defineInstance(IChatInputNotificationService, new class extends mock<IChatInputNotificationService>() {
|
||||
override readonly onDidChange = Event.None;
|
||||
override getActiveNotification() { return undefined; }
|
||||
override announceRendered() { }
|
||||
}());
|
||||
reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { override readonly model = new class extends mock<IAgentSessionsService['model']>() { override readonly onDidChangeSessions = Event.None; }(); }());
|
||||
// Agent-host chat widgets (e.g. the turn changes summary fixtures) create the
|
||||
|
||||
@@ -322,6 +322,7 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo
|
||||
reg.defineInstance(IChatInputNotificationService, new class extends mock<IChatInputNotificationService>() {
|
||||
override readonly onDidChange = Event.None;
|
||||
override getActiveNotification() { return undefined; }
|
||||
override announceRendered() { }
|
||||
}());
|
||||
reg.defineInstance(ICustomizationHarnessService, new class extends mock<ICustomizationHarnessService>() {
|
||||
override readonly onDidChangeSlashCommands = Event.None;
|
||||
|
||||
Reference in New Issue
Block a user