diff --git a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts index 798ccb8abc3..fbed12d5bdc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts @@ -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 " - // 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; readonly name: string; readonly identifier: string }>(); + const promoByHarness = new Map>(); 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(); - 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], 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 bc4f1c2791b..7fb1b22b695 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts @@ -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(); 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); } } 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 57e1488dece..f4d302219a6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts @@ -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; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 914e08aaec9..a0806eeb635 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -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 }); }, diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 3fada3186e9..e3bcb8e4e31 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -313,8 +313,8 @@ export interface ILanguageModelChatMetadata { */ readonly warningText?: IStringDictionary; /** - * 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 } { + 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. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts index 087fac8c787..816cf88bf27 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts @@ -62,6 +62,7 @@ class FakeNotificationService implements IChatInputNotificationService { dismissNotification(_id: string): void { /* */ } getActiveNotification(): IChatInputNotification | undefined { return undefined; } handleMessageSent(): void { /* */ } + announceRendered(): void { /* */ } } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts index 6fc679399c5..452647226a1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts @@ -62,6 +62,7 @@ function createMockNotificationService(disposables: Pick 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 " 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', () => { 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 420ffec5037..bc8b0e2d271 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -133,6 +133,7 @@ function createMockNotificationService() { return !filter || filter(lastNotification) ? lastNotification : undefined; }, handleMessageSent() { }, + announceRendered() { }, }; return { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts index e1a422babaf..b163acbee05 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts @@ -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 = Event.None; readonly onDidExecuteCommand: Event = Event.None; - async executeCommand(): Promise { + readonly executed: { readonly id: string; readonly args: readonly unknown[] }[] = []; + + async executeCommand(id: string, ...args: unknown[]): Promise { + 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(); + const announced: (IChatInputNotification | undefined)[] = []; + const onDidChange = store.add(new Emitter()); + 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('.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('.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'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index 5cc927c2d45..2bb71164df9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -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'); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index 7c2fea8febc..11e575d9370 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -238,6 +238,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.defineInstance(IChatInputNotificationService, new class extends mock() { override readonly onDidChange = Event.None; override getActiveNotification() { return undefined; } + override announceRendered() { } }()); reg.defineInstance(IAgentSessionsService, new class extends mock() { override readonly model = new class extends mock() { override readonly onDidChangeSessions = Event.None; }(); }()); // Agent-host chat widgets (e.g. the turn changes summary fixtures) create the diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index f5a9cd33e92..6125b495bc2 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -322,6 +322,7 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo reg.defineInstance(IChatInputNotificationService, new class extends mock() { override readonly onDidChange = Event.None; override getActiveNotification() { return undefined; } + override announceRendered() { } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { override readonly onDidChangeSlashCommands = Event.None;